캐시 데코레이터: lru_cache와 cache

functools.lru_cache, cache, cached_property의 동작 원리, LRU 알고리즘, 메모이제이션 패턴, 메서드에서의 메모리 누수 주의사항을 설명합니다.

· 5 min read · PALDYN Team

지난 글에서 @propertycached_property를 살펴봤다. 이번 글에서는 함수 결과를 캐싱하는 functools.lru_cachecache를 깊이 다룬다. 재귀 함수 최적화부터 API 응답 캐싱까지 실무에서 자주 쓰이는 패턴이다.

메모이제이션이란

**메모이제이션(memoization)**은 함수를 처음 호출한 결과를 저장해 두고, 같은 인자로 다시 호출되면 저장된 결과를 반환하는 최적화 기법이다. 순수 함수(같은 입력 → 항상 같은 출력)에만 올바르게 적용된다.

# 캐시 없이 피보나치: O(2^n) 호출 폭발
def fib(n):
    if n < 2: return n
    return fib(n-1) + fib(n-2)

# fib(40) → 약 2억 번 호출

fib(40) 하나 계산에 약 2억 번의 재귀 호출이 발생한다. fib(38)이 반복 계산되기 때문이다.

functools.cache (Python 3.9+)

from functools import cache

@cache
def fib(n: int) -> int:
    if n < 2: return n
    return fib(n-1) + fib(n-2)

print(fib(100))   # 즉시 계산, 100번만 호출

@cachelru_cache(maxsize=None)의 단순 별칭이다. 크기 제한이 없으므로 모든 결과가 영구 저장된다. 크기 추적 오버헤드가 없어 lru_cache보다 약간 빠르다.

functools.lru_cache (Python 3.2+)

from functools import lru_cache

@lru_cache(maxsize=128)
def fetch_user(user_id: int) -> dict:
    return db.query("SELECT ...", user_id)

info = fetch_user.cache_info()
# CacheInfo(hits=42, misses=8, maxsize=128, currsize=8)

fetch_user.cache_clear()   # 캐시 전체 비우기

maxsize=128이면 최근 128개의 (인자, 결과) 쌍을 저장한다. 128개를 초과하면 LRU(Least Recently Used) 알고리즘으로 가장 오래 전에 쓰인 항목을 제거한다.

cache_info()는 캐시 통계를 반환한다. hits(캐시 적중), misses(캐시 미적중), maxsize, currsize를 확인할 수 있다.

캐시 데코레이터 비교표

LRU 동작 원리

LRU 캐시는 내부적으로 딕셔너리 + 이중 연결 리스트로 구현된다. 캐시에 접근할 때마다 해당 항목이 리스트의 앞으로 이동한다. 리스트의 끝이 “가장 오래 안 쓰인” 항목이다.

# maxsize=3 캐시 예시
# 초기: []
fib(1) → [1]
fib(2) → [2, 1]
fib(3) → [3, 2, 1]
fib(4) → [4, 3, 2]  ← 1 제거 (LRU)
fib(2) → [2, 4, 3]  ← 2 MRU(앞)으로 이동
fib(1) → [1, 2, 4]  ← 3 제거 (LRU), 1 재계산

인자는 hashable이어야 한다

lru_cache/cache는 인자를 딕셔너리 키로 쓰므로 모든 인자가 hashable이어야 한다.

@cache
def process_list(data: list) -> int:   # TypeError!
    return sum(data)

# 리스트는 unhashable → 에러
process_list([1, 2, 3])

# 해결: tuple로 변환
@cache
def process_list(data: tuple) -> int:
    return sum(data)

process_list(tuple([1, 2, 3]))   # OK

딕셔너리를 캐시하고 싶다면 frozenset(d.items())나 별도의 직렬화가 필요하다.

lru_cache 코드 패턴

메서드에 cache 적용 시 메모리 누수

lru_cache를 인스턴스 메서드에 직접 쓰면 self가 캐시 키에 포함되어 메모리 누수가 생긴다.

class MyClass:
    @lru_cache(maxsize=None)   # 위험!
    def compute(self, x):
        return x * 2

obj = MyClass()
obj.compute(1)
# 캐시가 obj를 참조 → obj가 gc되지 않음
del obj   # 실제로 메모리 해제 안 됨

인스턴스 메서드에는 cached_property 또는 인스턴스별 캐시를 직접 구현한다.

from functools import cached_property

class MyClass:
    @cached_property
    def expensive_value(self):
        return compute_something()
    # 인스턴스 __dict__에 저장 → gc와 함께 해제됨

캐시 무효화

lru_cache는 TTL(Time-to-Live) 지원이 없다. 시간 기반 무효화가 필요하면 직접 구현해야 한다.

import time
from functools import wraps

def ttl_cache(seconds=60, maxsize=128):
    def decorator(func):
        cache = {}
        @wraps(func)
        def wrapper(*args):
            now = time.monotonic()
            if args in cache:
                result, ts = cache[args]
                if now - ts < seconds:
                    return result
            result = func(*args)
            cache[args] = (result, now)
            if len(cache) > maxsize:
                oldest = min(cache, key=lambda k: cache[k][1])
                del cache[oldest]
            return result
        wrapper.cache_clear = cache.clear
        return wrapper
    return decorator

@ttl_cache(seconds=300)
def get_config(key):
    return load_from_db(key)

지난 글: property 데코레이터 완전 이해

다음 글: deprecated 경고 데코레이터


읽어주셔서 감사합니다. 😊