파이썬에서 데코레이터를 정말 자주 사용하고 있지만 다양한 용례는 접해보지 못했었다. Ned Batchelder의 글 Isolated @memoize은 @memoize 데코레이터에 대한 이야기인데 같이 곁들여진 설명과 각 링크가 유익해서 번역했다. 파이썬 데코레이터 모음 위키 페이지도 살펴보면 좋겠다.
파이썬 @memoize 고립된 환경에서 사용하기
실행 비용이 비싼 함수를 호출한다고 생각해보자. 동일한 입력을 했을 때 동일한 결과를 반환하는 함수인 경우에는 사람들 대부분은 @memoize 데코레이터를 사용하는 것을 선호한다. 이 데코레이터는 이전에 실행한 적이 있는 경우에는 동일한 결과를 빠르게 내놓을 수 있도록 캐시해둔다. 다음은 @memoize 구현 모음을 차용해서 만든 간단한 코드다.
def memoize(func):
cache = {}
def memoizer(*args, **kwargs):
key = str(args) + str(kwargs)
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return memoizer
@memoize
def expensive_fn(a, b):
return a + b # 물론 이 함수는 그렇게 비싼 연산이 아니다!
이 코드는 원하는 동작을 제대로 수행하는 좋은 코드다. expresive_fn 함수를 동일한 인자로 반복해서 호출하면 실제로 함수를 호출하지 않고 캐시된 값을 사용할 수 있다.
하지만 여기에는 잠재적인 위험이 있다. 캐시 딕셔너리가 전역이라는 점이다. 물론 이 말을 문자 그대로 전역이라고 생각하는 잘못을 범하지 말자. global
키워드를 사용하지 않았고 모듈 단위의 변수인 것도 아니다. 하지만 이 딕셔너리는 프로세스 전체에서 expensive_fn를 대상으로 오직 하나의 캐시 딕셔너리를 갖고 있기 때문에 이 관점에서는 전역 변수라고 말할 수 있을 것이다.
전역은 잘 짜여진 테스트를 방해할 수 있다. 자동화된 테스트에서 가장 이상적인 동작 방식은 각 테스트가 서로 영향을 미치지 않도록 고립된 형태로 테스트를 수행하는 것이다. test1에서 어떤 일이 일어나든지 test99에는 영향이 없어야 한다. 하지만 여기서 test1부터 test99까지 expensive_fn을 (1, 2) 인자를 사용해서 호출했다면 test1은 함수를 호출하지만 test99는 캐시에 저장된 값을 사용한다. 더 나쁜 부분은 전체 테스트를 호출하면 test99는 캐시에 저장된 값을 사용하게 될 것인 반면 test99만 실행하면 함수를 실제로 실행하게 된다는 점이다.
만약 expensive_fn이 정말로 부작용 없는 순수 함수라면 이런 특징이 문제가 되지 않을 것이다. 하지만 때로는 문제가 되는 경우도 있다.
내가 관리하게 된 프로젝트 중에 고정된 데이터를 가져오기 위해 @memoize를 사용하는 웹사이트가 있었다. 자료를 가져올 때 단 한 번만 가져왔기 때문에 @memoize는 적절했고 프로그램을 사용하는데 전혀 문제가 되질 않았다. 테스트는 Betamax를 사용해서 네트워크 접근을 모조로 만들었다.
Betamax는 대단한 라이브러리다. 각 테스트 케이스를 구동할 때 각 테스트에서의 네트워크 접근을 확인한 후, “카세트”에 요청과 반환을 JSON 양식으로 저장한다. 다시 테스트를 수행하면 카세트에 저장되어 있는 정보를 사용해서 네트워크 접근을 모조해서 처리해준다.
문제는 test1의 카세트에서 캐시로 저장될 자원을 네트워크로 요청하게 되고 test99는 @memoize로 인해 네트워크를 통해 데이터를 요청할 필요가 없어졌기 때문에 test99의 카세트가 제대로 생성이 되지 않는다. 이제 테스트를 test99만 구동했을 때 카세트에 저장된 정보가 없기 때문에 테스트가 실패하게 된다. test1과 test99는 각각 고립되서 실행된다고 볼 수 없다. 저장된 캐시를 통해서 전역적으로 값을 공유하기 때문이다.
내 해결책은 @memoize를 사용했을 때 테스트 사이에 캐시 내용을 지우는 방식이다. 이 코드를 직접 작성하기 보다는 functools에 포함되어 있는 lru_cache 데코레이터를 사용할 수 있다. (여전히 2.7 버전의 파이썬을 사용하고 있다면 functools32을 찾아보자.) 이 데코레이터는 전역 캐시의 모든 값을 지울 수 있는 .cache_clear
함수를 제공한다. 이 함수는 각 데코레이터를 사용한 함수에 있기 때문에 사용한 함수를 목록으로 갖고 있어야 한다.
import functools
# memoize를 사용한 함수 목록을 저장. 그런 후
# `clear_memoized_values`로 일괄 비우기를 수행.
_memoized_functions = []
def memoize(func):
"""함수를 호출해서 반환한 값을 캐시로 저장함"""
func = functools.lru_cache()(func)
_memoized_functions.append(func)
return func
def clear_memoized_values():
"""@memoize에 저장된 모든 값을 비워서 각 테스트가 고립된 환경으로 동작할 수 있도록 함"""
for func in _memoized_functions:
func.cache_clear()
이제 각 테스트 전에 캐시를 비우기 위해 py.test의 픽스쳐에서, 또는 테스트 케이스의 setUp()
메서드에서 clear_memoized_values()
함수를 사용할 수 있다.
# py.test를 사용하는 경우
@pytest.fixture(autouse=True)
def reset_all_memoized_functions():
"""@memoize에 캐시로 저장된 값을 매 테스트 전에 비움"""
clear_memoized_values()
# unittest를 사용하는 경우
class MyTestCaseBase(unittest.TestCase):
def setUp(self):
super().setUp()
clear_memoized_values()
사실 @memoize를 사용하는 다양한 이유를 보여주는 것이 더 나을지도 모른다. 순수 함수는 모든 테스트에서 캐시를 사용해서 같은 값을 반환해도 문제가 없을 것이다. 연산이 필요한 경우라면 누가 이런 문제를 신경 쓸까? 하지만 다른 경우에서는 확실히 고립된 환경을 만들어서 사용해야 한다. @memoize는 마술이 아니다. 이 코드가 어떤 일을 하는지, 어떤 상황에서 더 제어가 필요한지 잘 알아야 한다.
오현석(enshahar)님 피드백을 받아 번역을 개선했습니다. 감사 말씀 드립니다.