[Python] cache 데코레이터로 최적화하기

최적화는 귀찮다. 눈에 띄는 실수를 한 게 아니면 어떻게 고쳐야 할지 감이 오지도 않고, 대부분의 최적화는 가독성을 떨어뜨리기 때문에 버그가 발생할 확률이 늘어난다. 하지만 어떤 최적화 테크닉은 코드를 크게 수정하지 않고 큰 성능 향상을 가져온다. 메모이제이션이 그 대표적인 예제다. 계산이 무겁거나, 디스크의 값을 읽거나, 네트워크 통신처럼 근본적으로 시간이 오래 걸리는 일은 그 실행 결과를 저장했다 재사용하는 것만으로 큰 성능향상을 가지고 온다.

파이썬은 메모이제이션을 쉽게 적용할 수 있는 데코레이터를 제공한다. functools 모듈의 lru_cache 데코레이터가 이것이다. 이 데코레이터를 붙이면 함수의 실행 결과를 캐싱해준다. 캐시의 크기는 maxsize로 지정할 수 있다. 저장할 실행 값이 이 개수를 넘어가는 경우 LRU 알고리즘에 따라 가장 오래전에 사용한 결과를 지우고 새 값을 캐싱한다.

lru_cache를 사용하면 쉽게 최적화할 수 있지만 아무 함수에나 사용할 수 있는 건 아니다. 함수의 인자를 캐시키로 사용하기 때문에 함수의 실행 결과가 함수의 인자 이외에 다른 요소에 의존적인 함수에는 사용하지 못한다. 즉, 랜덤 요소가 들어가거나 시간에 따라 결괏값이 변하는 함수에는 사용하면 안 된다.

결정성이 보장되는 함수에만 사용할 수 있다는 것은 모든 캐시의 공통적인 특성이다. 여기에 더해 파이썬이 제공하는 lru_cache는 그 구현상의 문제로 한 가지 제약이 더 있다. 이 데코레이터는 값을 저장하기 위해 인자를 키로 가지는 dictionary를 사용한다. 따라서 모든 인자가 hashable 타입이어야 한다. 다시 말해 mutable 하지 않은 dictionary, set, list 등을 인자로 받는 함수는 이 데코레이터를 사용해 캐싱할 수 없다. 이런 타입을 인자로 받던 함수는 그 인자를 frozenset이나 tuple 같은 immutable 타입으로 변환해야 한다.

게다가 keyword argument를 사용할 경우 keyword argument를 사용하는 순서에 따라 캐시키가 달라진다. 즉, f(a=1, b=2)f(b=2, a=1)은 실제로는 같은 값을 돌려주지만, 캐시에서는 서로 다른 공간을 차지한다.

조심해야 할 것은 또 있다. lru_cache는 저장된 객체를 복사하지 않고 그대로 돌려준다. 따라서 dictionary 같은 mutable 객체를 돌려주는 함수에 사용할 때 조심해야 한다.

이런 구현상의 이슈 때문에 인터페이스가 복잡한 함수에 lru_cache 데코레이터를 붙이는 것은 큰 작업이 될 수 있다. 인자로 사용하던 listset은 전부 tuplefrozenset으로 바꿔야 하고, 결괏값으로 dictionaryset 등을 받아 수정하는 일이 있는지 함수 호출하는 부분을 다 확인해줘야 한다. 그런데 이 작업을 쉽게 해결해줄 비법이 있다. 캐시 데코레이터가 붙은 함수를 바로 공개하는 것이 아니라, 이 함수를 감싸는 함수를 만들어 그 함수를 공개하는 것이다. 이렇게 하면 쓸데없는 복사가 일어나긴 하지만 lru_cache 데코레이터를 안전하게 사용할 수 있다.

캐시는 최적화다. 모든 최적화가 그렇듯이 이것도 데코레이터를 붙였다고 끝내는 것이 아니라 성능을 측정해 속도가 향상됐음을 확인해야 한다. 여기에 더해 캐시에는 한 가지 더 확인해야 할 것이 있다. 캐시가 효율적으로 사용되는지 실제 환경에서 확인해야 한다. lru_cache 데코레이터를 붙인 함수에는 cache_info라는 함수가 생긴다. 이 함수를 이용해 캐시 된 결괏값의 사용 빈도를 확인할 수 있다. 간헐적으로 돌리는 프로그램이라면 프로그램이 종료될 때, 죽지 않고 계속 돌아가는 서비스라면 주기적으로 로그에 남겨 캐시 사용 효율을 확인해 캐시 크기를 조정해줘야 한다.

이런 것이 귀찮으면 maxsizeNone으로 두거나, 크기지정 할 수 없는 cache 데코레이터를 사용하는 것도 방법이다. 이것을 사용하면 캐시 크기 제한 없이 모든 결괏값을 저장한다. 하지만 모든 값을 저장하는 경우 메모리를 많이 사용하기 때문에 계속 돌아가야 하는 서비스에는 사용하기 힘들다. 결국 데코레이터를 붙이는 것으로 끝이 아니라 꾸준한 관찰을 하며 메모리 사용량과 실행 시간 중에서 적절한 타협점을 찾아야 한다.

댓글

이 블로그의 인기 게시물

[MongoDB] ObjectId에 대해서

read-writers lock - 공유 자원 접근하기

[C++] enum class - 안전하고 쓰기 쉬운 enum