본문으로 건너뛰기
yutils

caching 은 어떻게 동작할까?

두 가지 어려운 문제 (invalidation + naming), cache hierarchy (CPU → browser → CDN → Redis → DB), cache-aside vs write-through vs write-behind, stampede / thundering herd, TTL 만으로 안 되는 이유.

약 9분 읽기

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).

가이드 목록으로