[Redis] Cache Patterns: Cache-Aside, Stampede, Eviction
정의
Cache Pattern 은 Redis (또는 다른 캐시) 를 어떤 경로로 읽고 쓰는지 의 디자인 결정. 같은 인프라로도 패턴 선택에 따라 일관성 / 지연 / 부하 가 완전히 달라진다.
가장 흔한 다섯 가지:
- Cache-aside (look-aside): 애플리케이션이 캐시를 직접 본다. miss 면 DB 로 가서 직접 캐시에 채운다.
- Read-through: 캐시 라이브러리 가 miss 시 자동 으로 backing store 를 조회.
- Write-through: write 시 캐시 + DB 동시 갱신. 일관성 ↑.
- Write-behind / Write-back: 캐시 먼저 쓰고 비동기 로 DB 반영. 쓰기 빠름, 손실 위험.
- Refresh-ahead: TTL 만료 직전 에 백그라운드 refresh. miss 자체를 줄임.
Cache Hit / Miss 의 시각
행복 경로 (HIT):
캐시 채우는 경로 (MISS → DB → set):
NOTE
위 두 애니메이션은 왼쪽 Client → Cache → DB 의 표준 cache-aside 패턴. HIT 는 ~6ms, MISS 는 DB 왕복까지 더해 ~72ms 의 직관 차이 를 보여준다.
5가지 패턴 비교
flowchart LR
subgraph CA[Cache-Aside]
A1[App] -->|GET| C1[Cache]
C1 -->|miss| A1
A1 -->|SELECT| D1[(DB)]
D1 --> A1
A1 -->|SET| C1
end
subgraph WT[Write-Through]
A2[App] -->|UPDATE| L2[Cache lib]
L2 -->|동시| C2[Cache]
L2 -->|동시| D2[(DB)]
end
subgraph WB[Write-Behind]
A3[App] -->|UPDATE| L3[Cache lib]
L3 -->|즉시| C3[Cache]
L3 -.비동기.-> D3[(DB)]
end
매트릭스:
| 패턴 | 일관성 | 읽기 지연 | 쓰기 지연 | 복잡도 | 흔한 곳 |
|---|---|---|---|---|---|
| Cache-aside | 약 (TTL 의존) | miss 만 느림 | DB 만 영향 | 낮음 | 기본값. Rails Rails.cache.fetch, Django |
| Read-through | 약 (TTL 의존) | miss 만 느림 | DB 만 영향 | 중간 | 캐시 라이브러리 / Hibernate L2 |
| Write-through | 강 (캐시 ↔ DB 같은 순간) | 캐시에서 즉시 | 둘 다 기다림 | 중간 | 결제 / 잔액 |
| Write-behind | 약 (지연된 일관성) | 캐시에서 즉시 | 캐시만 기다림 | 높음 (장애 시 손실) | 메트릭 / 로그 집계 |
| Refresh-ahead | 약 | 항상 빠름 | 백그라운드 | 높음 | 인기 콘텐츠, 메인 페이지 |
Cache-aside 의 정석
GET → miss → SELECT → SET:
sequenceDiagram
autonumber
participant App
participant Cache as Redis
participant DB as PostgreSQL
App->>Cache: GET user:42
alt HIT
Cache-->>App: 데이터 반환
else MISS
Cache-->>App: (nil)
App->>DB: SELECT * FROM users WHERE id=42
DB-->>App: row
App->>Cache: SET user:42 ... EX 600
Cache-->>App: OK
end
여러 언어로 같은 패턴:
# Rails 의 표준
def find_user(id)
Rails.cache.fetch("user:#{id}", expires_in: 10.minutes) do
User.find(id)
end
end동일 입력 → 동일 응답. miss 시 첫 호출만 DB 왕복.Cache Stampede (Thundering Herd / Dogpile)
TTL 이 동시에 만료 되면 수많은 클라이언트 가 동시에 DB 를 친다. 원래 막아주던 캐시 가 증폭기 가 되는 사고.
sequenceDiagram
autonumber
participant C1
participant C2
participant C3
participant Cache
participant DB
Note over Cache: TTL 0, 만료된 순간
par C1, C2, C3 모두 동시에
C1->>Cache: GET
Cache-->>C1: nil
C2->>Cache: GET
Cache-->>C2: nil
C3->>Cache: GET
Cache-->>C3: nil
end
par 모두 DB 로 (stampede!)
C1->>DB: SELECT
C2->>DB: SELECT
C3->>DB: SELECT
end
par 같은 결과를 캐시에 N 번 SET
C1->>Cache: SET
C2->>Cache: SET
C3->>Cache: SET
end
완화 패턴 4가지
| 패턴 | 동작 | 단점 |
|---|---|---|
| Single-flight (mutex) | SET key:lock NX EX 잡은 한 명 만 DB 조회, 나머지는 기다림 / 옛 값 사용 | 락 노드 장애 시 데드락 위험 |
| Probabilistic Early Expiration (XFetch) | 남은 TTL 의 함수 로 확률적 미리 갱신. 만료 전에 극소수 가 갱신 | 알고리즘 약간 복잡 |
| Stale-while-revalidate | TTL 만료 후에도 N 초간 옛 값 응답 + 백그라운드 갱신 | 완전 stale 응답을 허용해야 함 |
| Jitter on TTL | TTL = base + random(±N%) | 동시 만료 자체 만 분산. 완전 해결 X |
XFetch (Probabilistic Early Expiration)
논문 (Vattani et al.) 의 공식:
expiry = now + delta * beta * log(rand())
캐시에 기록 시점에 평균 fetch 시간 delta 를 함께 저장. 읽을 때 위 식이 TTL 만료 시점을 넘으면 자기가 갱신.
import math, random, time
def get_with_xfetch(r, key, fetch_fn, ttl_seconds, beta=1.0):
payload = r.get(key)
if payload is None:
return refresh(r, key, fetch_fn, ttl_seconds)
value, delta, expiry = decode(payload)
now = time.time()
if now - delta * beta * math.log(random.random()) >= expiry:
# 만료 전이지만 *확률적으로 미리 갱신*
return refresh(r, key, fetch_fn, ttl_seconds)
return value
Single-flight: SET NX 락
def cache_aside_with_lock(r, key, fetch_fn, ttl=600):
val = r.get(key)
if val is not None:
return val
# 락 시도
lock_key = f"lock:{key}"
got = r.set(lock_key, "1", nx=True, ex=10)
if got:
try:
fresh = fetch_fn()
r.set(key, fresh, ex=ttl)
return fresh
finally:
r.delete(lock_key)
else:
# 다른 클라이언트가 갱신 중. 잠시 기다렸다가 재시도
time.sleep(0.05)
return cache_aside_with_lock(r, key, fetch_fn, ttl)
Eviction Policy (maxmemory-policy)
메모리 한도 (maxmemory) 초과 시 어떤 키부터 버릴지. 패턴 선택만큼 중요.
| Policy | 동작 | 권장 시나리오 |
|---|---|---|
noeviction (기본) | 메모리 초과 시 write 에러 | 데이터 손실 절대 안 됨 (primary store) |
allkeys-lru | 모든 키 중 최근 덜 쓰인 (LRU) 제거 | 순수 캐시 |
allkeys-lfu | 덜 자주 쓰인 (LFU) 제거 | 인기 분포 편중 환경 (롱테일) |
allkeys-random | 무작위 | LRU 비용도 아까울 때 (드물게) |
volatile-lru | TTL 설정된 키 중 LRU | 세션 + 영구 데이터 혼합 |
volatile-lfu | TTL 키 중 LFU | 동일, 분포 편중 |
volatile-ttl | 남은 TTL 짧은 키부터 | TTL 의 의미가 우선순위 일 때 |
volatile-random | TTL 키 중 무작위 | 드물게 |
LRU 의 동작 직관
NOTE
Redis 의 LRU 는 근사 LRU (maxmemory-samples 만큼 sampling). 정확한 LRU 가 아니다. maxmemory-samples 10 정도가 정확도 / 비용의 보통 타협점.
Eviction 성능 비교 (직관)
같은 워크로드, 다른 정책에서의 히트율 차이 의 직관:
Negative Caching (404 / null 도 캐시)
DB miss 자체가 비싸면 결과 없음 도 캐시.
def get_user(id):
cached = r.get(f"user:{id}")
if cached == NULL_SENTINEL:
return None # *없음* 도 캐시
if cached is not None:
return json.loads(cached)
user = db_lookup(id)
if user is None:
r.set(f"user:{id}", NULL_SENTINEL, ex=60) # 짧은 TTL
else:
r.set(f"user:{id}", json.dumps(user), ex=600)
return user
CAUTION
Negative cache TTL 은 짧게 (예: 60초). 안 그러면 방금 가입한 사용자가 한참 동안 안 보이는 버그.
무효화 (Invalidation)
“There are only two hard things in Computer Science: cache invalidation and naming things.” (Phil Karlton)
Write 시 캐시 무효화 (가장 흔함)
def update_user(id, **fields):
db.update("UPDATE users SET ... WHERE id=%s", id, **fields)
r.delete(f"user:{id}") # 다음 GET 이 miss → 새로 채움
SET으로 갱신 보다DEL이 일반적으로 안전. 멀티 키 / 파생 캐시까지 정확히 갱신하기 어렵기 때문에 지우고 다시 빠뜨리는 패턴.
Tagged Invalidation (Russian Doll)
특정 그룹의 캐시 일괄 무효화 가 필요할 때. 키에 버전 / 타임스탬프 를 박는다.
def get_post(post_id):
version = r.get(f"post:{post_id}:version") or "0"
return cached_get(f"post:{post_id}:v{version}")
def invalidate_post(post_id):
r.incr(f"post:{post_id}:version") # 옛 키들은 *자연 만료* 만 기다림
김신건의 현장 메모
- Rails.cache.fetch 의 race_condition_ttl 이 single-flight + stale-while-revalidate 의 내장 버전. 인기 페이지에 반드시.
- Sidekiq job 결과 를 캐시할 때 영구 캐시 + 명시적 invalidation 이 짧은 TTL 보다 DB / Redis 모두 가벼웠다.
- Memcached 에서 Redis 로 갈아탄 가장 큰 이유: negative cache 와 데이터 구조 였다. Memcached 는 단순 캐시 의 끝까지 단단 하지만, 해시 / 정렬셋 / 집합 이 필요해지면 결국 Redis.
- LFU 가 LRU 보다 좋은 경우 의 직관: “대부분의 트래픽은 인기 콘텐츠 20%” 이면 LFU 가 강함. 균일 분포 에서는 LRU 와 LFU 가 비슷.
관련 위키
- Redis (라이센스 / 신 기능)
- Redis Persistence (캐시 전용일 때 영속화 비활성)
- Redis Pub Sub vs Streams (캐시 무효화 fan-out)
- Zero Downtime Deployment (배포 시 캐시 워밍)
참고
- 공식: Eviction policies
- AWS: Caching Best Practices
- Wikipedia: Cache stampede
- Cloudflare: SWR 적용
이 글의 용어 (4개)
- [DB Internals] Redis 8 / Valkey 9: 라이센스 분기, 신 데이터 구조, 실전 운영database-internals
- 정의 Redis 는 in-memory key-value 데이터 구조 서버. 2009년 Salvatore Sanfilippo (antirez) 가 Lua Manuscripts 의 …
- [DevOps] 무중단 배포: Blue-Green, Canary, Rolling, Expand-Contractdevops
- 정의 무중단 배포 (Zero-Downtime Deployment) 는 서비스 가동을 멈추지 않고 새 버전을 배포하는 일련의 전략. 핵심 도전 4가지: 1. 트래픽 전환: 어떻게 …
- [Redis] Persistence: RDB / AOF / Hybriddatabase-internals
- 정의 Redis Persistence 는 in-memory 데이터를 디스크 로 지속화하는 메커니즘. 세 가지 옵션: 1. RDB (Redis Database file): 주기적 …
- [Redis] Pub/Sub vs Streams: 휘발 신호 vs 영속 로그database-internals
- 정의 - Pub/Sub ( / ): 지금 듣고 있는 구독자 에게만 메시지가 전달되는 휘발성 신호. 영속 없음, ACK 없음. fan-out. - Streams ( / / / ):…
이 개념을 다룬 위키 페이지 (12)
- wiki연결 리스트 (Linked List)
- wiki[API Design] GraphQL: 단일 endpoint, N+1, persisted queries
- wiki[Auth] Session Cookie: HttpOnly, SameSite, Secure flag
- wiki[DB Internals] Redis 8 / Valkey 9: 라이센스 분기, 신 데이터 구조, 실전 운영
- wiki[Redis] Hash: 객체 저장, Field TTL (7.4+), Listpack vs Hashtable
- wiki[Redis] Bloom / Cuckoo / CMS / TopK: 확률 자료구조
- wiki[Redis] Pub/Sub vs Streams: 휘발 신호 vs 영속 로그
- wiki[Redis] Sorted Set: Skiplist + Dict, 리더보드, 우선순위 큐
- wiki[Redis] String / Bitmap / Bitfield: 가장 기본 + 가장 강력
- wiki[Redis] Vector Search: HNSW, SVS-VAMANA, Semantic Cache
- wiki[Pattern] CQRS: Command / Query 분리, read model
- wiki[DRF] Pagination + Throttling
💬 댓글