[LazyClaude 심층 1] TTL + mtime 캐시 — 같은 파일을 두 번 파싱하지 않기
0.97s → 0.026s, 37배. 단순한 두 변수가 만든 차이
LazyClaude 심층 시리즈 1편. “가장 빠른 코드는 실행되지 않은 코드” — 캐시는 그 격언의 가장 단순한 구현이다.
문제
LazyClaude의 Hooks 탭은 첫 페인트가 1.94초 동안 멈췄다. 원인은 단순했다:
~/.claude/projects/*/sessions/*.jsonl— 누적 90MB- 매 요청마다 전체 스캔
- 정규식으로 hook block 라인을 추출
- JSON 파싱 → 분류 → 직렬화
이 작업이 데이터가 변하지 않아도 매번 반복됐다. 90MB의 jsonl을 1.94초마다 다시 읽는 건 명백히 낭비다.
패턴 — TTL + mtime 더블 키
캐시 키 두 개를 함께 본다:
| 키 | 역할 |
|---|---|
| TTL (예: 60s) | 외부 변경 가능성에 대한 안전망 |
| mtime | 실제로 파일이 변했을 때만 무효화 |
둘 다 통과하면 이전 결과를 그대로 돌려준다. 하나라도 깨지면 재계산.
핵심은 mtime이다. 파일이 바뀌지 않았는데 TTL만으로 캐시를 비우면, 무거운 작업을 60초마다 재실행하게 된다. 파일이 자주 바뀌어도 TTL을 짧게 두면 안전성이 확보된다.
Python 구현 (stdlib만)
import os, time, threading
from functools import wraps
_cache = {}
_lock = threading.Lock()
def cached_with_mtime(paths_fn, ttl_seconds=60):
"""
paths_fn(*args, **kwargs) -> Iterable[str] : 의존하는 파일 경로
"""
def deco(fn):
key = fn.__qualname__
@wraps(fn)
def inner(*args, **kwargs):
paths = list(paths_fn(*args, **kwargs))
mtimes = tuple(_safe_mtime(p) for p in paths)
sig = (mtimes, args, tuple(sorted(kwargs.items())))
now = time.time()
with _lock:
hit = _cache.get(key)
if hit and hit["sig"] == sig and now - hit["t"] < ttl_seconds:
return hit["v"]
value = fn(*args, **kwargs)
with _lock:
_cache[key] = {"sig": sig, "v": value, "t": now}
return value
return inner
return deco
def _safe_mtime(p):
try:
return os.path.getmtime(p)
except OSError:
return 0.0
사용 예:
@cached_with_mtime(
paths_fn=lambda: _list_jsonl_files(),
ttl_seconds=60,
)
def get_recent_blocks():
# 90MB scan + regex + JSON parse
...
측정 결과
| 시나리오 | 콜드(ms) | 웜(ms) | 배수 |
|---|---|---|---|
/api/hooks/recent-blocks |
970 | 26 | 37× |
| Skills 탭 (1.4MB 스캔) | 816 | 37 | 22× |
| Commands 탭 | 1116 | 36 | 31× |
측정 후 최적화 원칙대로, 콜드/웜을 분리해 측정하고 변화량으로 효과를 검증했다.
함정 — 안 보이게 깨지는 캐시
이 패턴에서 가장 자주 만나는 사고:
- 디렉터리 추가/삭제를 못 잡는다:
mtime은 파일별 이다. 새 파일이 생기면paths_fn이 그 사실을 알아야 한다 → 디렉터리도 의존성에 포함. - 시계 역전(clock skew): NTP 동기화로 mtime이 미래에서 과거로 이동하면 TTL 비교가 깨진다 →
abs(now - hit["t"])또는 monotonic clock 사용. - race condition: lock 없이 dict를 만지면 동시 요청에서 중복 계산 + 잘못된 결과. 위 코드에는
_lock을 둠. - 메모리 누수: 키 다양성이 높으면
_cache가 무한 성장. LRU + 상한 권장 (functools.lru_cache(maxsize=…)또는 직접 큐).
어디에 쓰면 좋은가
- 읽기는 잦고 쓰기는 드문 파일 기반 데이터 (설정, 로그 인덱스, 마크다운 모음)
- 계산 자체가 비싼 경우 (정규식 수십 개, JSON 파싱 수백 건)
- 캐시 무효화 키로 쓸 명시적 신호 (mtime, etag, version) 가 있는 경우
DB나 외부 API에는 부적합 — 그쪽은 ETag·서버 캐시·CDN이 담당.
한 줄 요약
같은 작업을 두 번 하지 마라. 단, 안 변했다는 증거 가 있을 때만.
다음 편: LazyClaude 심층 2 — 워크플로우 캔버스 최적화