- Published on
Python lru_cache와 제너레이터 캐시 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치 파이프라인에서 비용이 큰 연산을 줄이려고 functools.lru_cache를 붙이는 건 흔한 최적화입니다. 그런데 반환값이 제너레이터(generator) 또는 이터레이터(iterator) 인 함수에 lru_cache를 붙이면, "첫 호출은 정상인데 두 번째부터 결과가 비어버림" 같은 이상 현상이 발생할 수 있습니다.
이 글은 lru_cache와 제너레이터 조합에서 생기는 전형적인 버그를 재현 코드로 확인하고, 왜 그런지(캐시의 본질과 이터레이터의 수명)와 함께 안전한 해결 패턴을 정리합니다. 캐시 문제는 파이썬만의 이슈가 아니라 시스템 전반에서 반복되는 주제이기도 해서, 캐시가 꼬였을 때의 관점은 Next.js App Router RSC 캐시 꼬임 해결법에서도 비슷하게 도움이 됩니다.
문제: lru_cache로 제너레이터를 캐싱하면 무슨 일이 생기나
핵심은 간단합니다.
lru_cache는 함수 호출 결과 객체를 그대로 저장합니다.- 제너레이터는 한 번 순회하면 소모(consumed) 됩니다.
- 따라서 캐시된 제너레이터 객체를 두 번째 호출에서 다시 받으면, 이미 소모된 상태라 빈 결과가 나오거나 중간부터 시작합니다.
재현 코드: 두 번째 호출이 빈 리스트가 되는 버그
아래 예시는 "DB에서 페이지 단위로 읽어 yield" 하는 상황을 단순화한 것입니다.
from functools import lru_cache
@lru_cache(maxsize=128)
def iter_numbers(n: int):
print("compute")
for i in range(n):
yield i
print(list(iter_numbers(3)))
print(list(iter_numbers(3)))
실행 결과는 보통 이렇게 나옵니다.
- 첫 번째:
compute출력 후[0, 1, 2] - 두 번째:
compute는 출력되지 않지만[]
왜냐하면 두 번째 호출은 새 제너레이터를 만드는 게 아니라 첫 호출에서 만들어 캐시에 들어간 같은 제너레이터 객체를 그대로 돌려주기 때문입니다.
더 위험한 형태: 여러 소비자가 같은 제너레이터를 공유
제너레이터는 상태를 내부에 들고 있고, next()로 진행되면서 상태가 바뀝니다. 캐시된 제너레이터를 여러 곳에서 동시에(또는 교차로) 순회하면 결과가 섞이거나 누락됩니다.
from functools import lru_cache
@lru_cache(maxsize=1)
def stream():
for i in range(5):
yield i
it1 = stream()
it2 = stream()
print(next(it1)) # 0
print(next(it2)) # 1 (같은 객체라 진행 상태 공유)
print(list(it1)) # [2, 3, 4]
print(list(it2)) # []
이런 버그는 테스트에서는 잘 안 보이다가, 프로덕션에서 "어떤 요청은 데이터가 일부만 내려온다" 같은 형태로 나타나 디버깅을 매우 어렵게 만듭니다.
원인: lru_cache는 "값"이 아니라 "객체"를 캐싱한다
lru_cache는 "같은 인자로 호출하면 같은 결과"라는 가정(참조 투명성)에 기대는 메모이제이션입니다. 그런데 제너레이터는 다음 성질 때문에 이 가정을 깨뜨립니다.
- 단발성(one-shot): 한 번 순회하면 끝
- 상태 보유(stateful): 어디까지 읽었는지가 객체 내부에 저장
- 외부 효과에 취약: 소비 시점에 파일/소켓/DB 커서를 읽는 경우, 캐시된 객체가 과거의 환경을 붙잡을 수 있음
결국 lru_cache가 저장하는 것은 "결과 데이터"가 아니라 "미완성 스트림 객체"이며, 그 스트림은 소비되면서 변합니다.
흔한 실수 패턴 3가지
1) 제너레이터 함수에 그대로 lru_cache 붙이기
앞서 본 재현 코드가 대표적입니다. yield가 들어가면 그 함수는 즉시 제너레이터 함수가 됩니다.
2) 이터레이터를 반환하는 라이브러리 결과를 캐싱
예를 들어 map, filter, itertools.groupby 같은 결과는 대부분 이터레이터입니다.
from functools import lru_cache
@lru_cache(maxsize=128)
def get_even(nums: tuple[int, ...]):
return filter(lambda x: x % 2 == 0, nums)
print(list(get_even((1, 2, 3, 4))))
print(list(get_even((1, 2, 3, 4)))) # 두 번째는 비어버릴 수 있음
3) 리소스 핸들을 감싼 제너레이터 캐싱
파일을 읽는 제너레이터를 캐싱하면, 파일 디스크립터 수명/위치가 꼬입니다.
from functools import lru_cache
@lru_cache(maxsize=32)
def read_lines(path: str):
with open(path, "r", encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
첫 호출에서 with 블록이 끝나면 파일은 닫힙니다. 그런데 캐시에 남는 것은 "닫힌 파일을 기반으로 만들어진 제너레이터"일 수 있고, 소비 타이밍에 따라 ValueError 혹은 빈 결과가 나올 수 있습니다.
해결책: 캐시에는 "재사용 가능한 값"을 넣어라
정답은 보통 아래 중 하나입니다.
- 제너레이터를 즉시 실체화(materialize) 해서
tuple또는list로 캐시 - 캐시에는 제너레이터가 아니라 팩토리(새 이터레이터를 만드는 함수) 를 저장
- 애초에
lru_cache대신 목적에 맞는 캐시(예: TTL 캐시, 외부 캐시)를 사용
아래는 실무에서 많이 쓰는 안전한 패턴들입니다.
패턴 A: tuple(...)로 실체화해서 캐시하기 (가장 단순)
from functools import lru_cache
def iter_numbers(n: int):
for i in range(n):
yield i
@lru_cache(maxsize=128)
def numbers_cached(n: int) -> tuple[int, ...]:
return tuple(iter_numbers(n))
print(list(numbers_cached(3)))
print(list(numbers_cached(3)))
장점
- 재호출 시 항상 동일한 결과
- 소비자가 여러 명이어도 안전
단점
- 메모리를 더 사용
- 무한 스트림에는 적용 불가
패턴 B: 캐시는 "팩토리"로, 호출마다 새 제너레이터 생성
"큰 결과를 한 번에 메모리에 올리기 어렵다"면, 캐시할 대상을 바꿔야 합니다. 예를 들어 원천 데이터(파일 경로, 쿼리 문자열, 파라미터 등)를 캐시하고, 매번 새 스트림을 만들게 합니다.
from functools import lru_cache
from collections.abc import Iterator
@lru_cache(maxsize=128)
def query_plan(sql: str) -> str:
# 예: SQL 파싱/플랜 생성이 비싸다고 가정
return sql.strip().lower()
def rows(sql: str) -> Iterator[int]:
plan = query_plan(sql)
# plan을 바탕으로 매번 새 이터레이터를 만든다
for i in range(3):
yield i
print(list(rows("SELECT * FROM t")))
print(list(rows("SELECT * FROM t")))
핵심은 lru_cache가 붙은 함수의 반환값이 순수 데이터(불변/재사용 가능) 여야 한다는 점입니다.
패턴 C: 제너레이터를 캐시하고 싶다면 "리플레이 가능한 컨테이너"를 만든다
상황에 따라서는 스트리밍 처리와 캐시 재사용을 동시에 원합니다. 이때는 결과를 디스크에 저장하거나, 청크 단위로 버퍼링하는 구조가 필요합니다.
간단한 형태로는 첫 소비 때 모두 읽어 저장해 두고, 이후에는 저장된 값을 재생(replay)하는 래퍼를 둘 수 있습니다.
from collections.abc import Iterable, Iterator
class Replayable(Iterable[int]):
def __init__(self, source: Iterator[int]):
self._source = source
self._buf: list[int] = []
self._done = False
def __iter__(self):
idx = 0
while True:
if idx < len(self._buf):
yield self._buf[idx]
idx += 1
continue
if self._done:
return
try:
v = next(self._source)
except StopIteration:
self._done = True
return
self._buf.append(v)
idx += 1
yield v
이 방식은 "처음 한 번은 스트리밍"으로 처리하고, 이후에는 메모리에 쌓인 범위 내에서 재사용할 수 있습니다. 다만 결국 버퍼가 커지면 메모리를 먹으니, 실무에서는 파일 캐시나 외부 캐시로 확장하는 편이 많습니다.
캐시 키 설계와 타입: 또 다른 함정
제너레이터 문제를 고쳤는데도 lru_cache가 기대대로 동작하지 않는 경우가 있습니다. 대표 원인은 키로 쓰인 인자가 해시 불가능하거나, 해시 가능하지만 의미가 불안정한 경우입니다.
list,dict,set은 기본적으로 해시 불가라TypeError가 납니다.- 커스텀 객체를 키로 쓰면
__hash__/__eq__구현에 따라 캐시 히트가 예상과 다를 수 있습니다.
실무 팁
- 입력 컬렉션은
tuple로 정규화해서 키로 사용 - 옵션은
frozenset또는 정렬된tuple로 정규화
from functools import lru_cache
@lru_cache(maxsize=256)
def compute(user_id: int, flags: tuple[str, ...]):
return (user_id, flags)
def compute_wrapper(user_id: int, flags: list[str]):
return compute(user_id, tuple(sorted(flags)))
디버깅: 캐시가 정말 원인인지 빠르게 확인하는 법
cache_info()로 히트/미스 확인
from functools import lru_cache
@lru_cache(maxsize=128)
def f(x: int):
return x * 2
f(1); f(1); f(2)
print(f.cache_info())
여기서 히트가 늘어나는데 결과가 비정상이라면, "캐시된 객체가 가변/소모형"일 가능성이 큽니다.
문제 재현을 위해 캐시 비우기
f.cache_clear()
재현 시나리오에서 캐시를 비웠을 때만 정상이라면, 캐시 오염(잘못된 값 캐싱) 또는 캐시된 객체의 상태 변화(제너레이터 소모)가 의심됩니다.
실무 체크리스트
lru_cache를 붙인 함수가yield를 포함하는가- 반환값이
generator,iterator,map,filter등 단발성 객체인가 - 반환값이 파일 핸들/DB 커서/네트워크 스트림 같은 외부 리소스를 붙잡고 있는가
- 캐시된 객체가 여러 소비자에게 공유될 수 있는 구조인가
- 캐시 키(인자)가 해시 가능하며, 의미적으로 안정적인가
캐시는 "빠르게" 만들 수 있지만, 잘못 캐시하면 "조용히" 망가집니다. 재시도/백오프처럼 시스템의 회복력을 설계할 때도 캐시 오염은 큰 장애 요인이 될 수 있는데, 그런 관점은 Azure OpenAI 429/503 재시도·백오프 설계 가이드 같은 글과 함께 보면 운영 설계에 도움이 됩니다.
결론
lru_cache는 강력하지만, 제너레이터/이터레이터와 결합하면 "캐시"가 아니라 "소모된 객체 공유"가 되어 버그를 만듭니다. 해결의 핵심은 단 하나입니다.
- 캐시에는 재사용 가능한 값(불변 데이터) 을 저장하고
- 스트리밍이 필요하면 매 호출마다 새 이터레이터를 만들거나, 리플레이 가능한 별도 구조를 사용합니다.
코드 리뷰에서 @lru_cache가 보이면, 반환 타입이 제너레이터인지부터 확인하는 습관만으로도 꽤 많은 장애를 예방할 수 있습니다.