Phil Karlton: "computer science 의 가장 어려운 두 문제 — cache invalidation, naming, off-by-one error". 농담 같지만 진실. caching 은 가장 큰 성능 도구이자 가장 큰 버그 원인. 이 가이드는 cache hierarchy, 4 가지 write 패턴, stampede 함정, TTL 만으로 안 되는 이유를 정리한다.
Cache Hierarchy — 가까운 쪽이 빠름
User browser
↓
Browser cache (HTTP cache) ~ 1ms (local disk)
↓
CDN edge cache (CloudFront, Cloudflare) ~ 10-50ms (지리)
↓
Reverse proxy cache (Varnish, nginx) ~ 1ms (data center)
↓
Application cache (in-memory, Redis) ~ 1-5ms
↓
Database query cache (Postgres, MySQL) ~ 10-50ms
↓
Database disk ~ 100ms+
→ 위쪽 hit 시 아래 단계 안 거침. cost / latency 큰 폭 절감.
→ 그러나 layer 마다 stale 가능성 ↑ — invalidation 의 게임.4 가지 write 패턴
1. Cache-aside (가장 흔함)
read:
data = cache.get(key)
if data is null:
data = db.query(key)
cache.set(key, data, ttl=5min)
return data
write:
db.update(key, value)
cache.delete(key) ← 또는 cache.set(new value)
장점: 단순. application 이 caching logic 통제.
단점:
- read 미스 시 latency penalty (DB 왕복)
- write 와 cache 의 race (다른 process 가 동시에 stale 데이터 cache)
- "thundering herd" — 캐시 expire 직후 N 사용자가 모두 DB 직접 호출2. Write-through
write:
cache.set(key, value)
db.update(key, value)
→ 둘 다 성공해야 commit
장점: cache 와 DB 가 항상 일치
단점:
- write latency = cache + DB 합산
- 거의 안 읽는 데이터도 cache 차지 (cache pollution)3. Write-behind (write-back)
write:
cache.set(key, value)
queue.push("update DB later")
→ 즉시 반환 (DB write 비동기)
장점: 매우 빠른 write
단점:
- queue / cache 죽으면 data loss
- read-after-write 일관성 깨질 수 있음
- 거의 사용 X (high-write workload 의 특수 경우만)4. Refresh-ahead
background worker 가 TTL 만료 직전 미리 refresh:
cron: 매분 모든 cache key 의 만료시각 검사
만료 < 30s 면 → re-fetch 후 cache 갱신
장점: read 시 항상 cache hit (no penalty)
단점:
- 안 읽힐 key 도 refresh (waste)
- 구현 복잡, 모니터링 부담Stampede / Thundering Herd
인기 페이지의 cache TTL 5 분. 5분 후 expire 시점:
t=0 t=5min t=5min+ε
┌───┐ ┌────────┐ ┌────────────────┐
│OK │ │ EXPIRE │ │ 1000 users hit │
└───┘ └────────┘ │ DB at once! │
└────────────────┘
→ 1000 동시 DB query → DB overload → 추가 사용자도 fail (cascading)
해결 1 — Lock (single-flight):
cache miss 시:
if redis.setnx("lock:key", 1, ex=30s):
data = db.query(...)
cache.set(key, data)
redis.del("lock:key")
else:
sleep(50ms); retry cache ← 다른 worker 가 채울 때까지 대기
해결 2 — stale-while-revalidate:
expire 후에도 stale data 응답 (다음 사용자가 background refresh trigger)
→ user 누구도 wait X, stale 1-2 초만 노출
해결 3 — Random jitter:
TTL 5 분 ± random(60s)
→ expire 가 분산되어 동시 hit 줄임TTL 만으로 안 되는 이유
scenario: user 가 자기 이름 변경 (Alice → A.)
t=0 : user GET /me → "Alice" (cache + DB)
t=1 : user PATCH /me → "A."
DB updated, but cache 는 "Alice" 그대로
t=2 : user GET /me → "Alice" ← stale!
TTL 5분 후 자동 update — 그러나 그 사이 사용자 혼란.
해결:
- write 시 명시적 invalidate (cache.del("user:42"))
- ETag / version 으로 client 가 무효 데이터 감지
- 짧은 TTL (10-30 초) — 그래도 race window 존재
- pub/sub — write 시 모든 cache 에 broadcast (Redis Cluster, NATS)
- event sourcing 의 projection — 변경 event 가 cache 자동 갱신 (별도 가이드)Cache Key 디자인
Naming 의 함정 — 2 가지 hard problem 중 하나.
❌ 나쁨:
key = "user_data" ← user 별 구분 X
key = "users" ← stale 시점에 무엇이 stale 인지 모름
✓ 좋음:
key = "user:42"
key = "user:42:posts:page:1"
key = "session:abc123"
key = "config:v3" ← version 박음, 새 deploy 시 자동 무효
규칙:
- prefix:id:variant 패턴 (분리자 / namespace)
- version 또는 schema hash 박기 (deploy 시 자동 cold start)
- locale / user 변수 명시 (cache 1 user 의 데이터를 다른 user 가 봐서는 안 됨)흔한 함정
- cache invalidate 잊음 — update 후 stale 노출. 모든 write path 의 invalidate review.
- negative caching 없음 — "not found" 결과도 cache. 안 하면 같은 missing key 매번 DB.
- user 데이터에 shared cache — user A 가 user B 의 데이터 봄. key 에 user_id 박기.
- cache size 무한 증가 — eviction policy (LRU, LFU) 명시. memory exhaustion + restart.
- cache 가 source of truth 가정 — Redis 죽으면 전체 down. cache 는 optimization, fallback 은 DB.
마무리
Caching 의 본질 — 같은 데이터 여러 곳에 복사 + 동기화. 성능 큰 승, 그러나 invalidation 의 hard problem. layer 마다 정확한 의도와 TTL.
실용 — cache-aside + stale-while-revalidate + version 박은 key 가 modern 표준. write 시 명시적 invalidate, stampede 방어 (lock 또는 jitter), monitoring (hit rate, eviction count).