이 글에서 얻는 것
- Cache Stampede의 구조적 원인을 설명할 수 있습니다.
- 분산 락 + 조기 만료 + 이중 캐시를 조합하는 실전 패턴을 이해합니다.
- 실제 코드로 안전한 락 획득/해제를 구현할 수 있습니다.
1) 문제 상황: TTL 만료 순간 폭발
핫 키가 만료되는 순간, 동시에 수천 개 요청이 DB로 쏠리면 DB가 먼저 죽습니다. 이를 막기 위해 단순 TTL 외에 추가 전략이 필요합니다.
2) 기본 전략 3가지
2-1. 분산 락으로 단일 재생성 보장
한 번에 한 요청만 DB를 조회해 캐시를 다시 채우게 합니다.
public String getUserProfile(String userId) {
String key = "user:" + userId;
String cached = redis.get(key);
if (cached != null) return cached;
String lockKey = "lock:" + key;
String token = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, token, 5, TimeUnit.SECONDS, NX);
if (!locked) {
sleep(80); // 짧게 대기 후 재시도
return redis.get(key); // 재조회
}
try {
String fresh = db.loadUser(userId);
redis.set(key, fresh, 60, TimeUnit.SECONDS);
return fresh;
} finally {
// 안전한 락 해제: 토큰 검증
releaseLock(lockKey, token);
}
}
Lua로 안전 해제:
-- unlock.lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
2-2. 조기 만료 (Probabilistic Early Expiration)
TTL이 끝나기 전에 확률적으로 갱신하여 요청을 분산시킵니다.
boolean shouldRefresh(double ttlLeftSec) {
// 만료가 가까울수록 확률을 증가
double p = Math.exp(-ttlLeftSec / 10.0);
return Math.random() < p;
}
- 10초 남았을 때 30% 갱신
- 2초 남았을 때 80% 갱신
2-3. 이중 캐시 (L1/L2)
- L1 (로컬 캐시, Caffeine): 초고속, 짧은 TTL
- L2 (Redis): 공유 캐시, 긴 TTL
String get(String key) {
String v = localCache.getIfPresent(key);
if (v != null) return v;
v = redis.get(key);
if (v != null) {
localCache.put(key, v);
return v;
}
v = db.load(key);
redis.set(key, v, 60, TimeUnit.SECONDS);
localCache.put(key, v);
return v;
}
3) 운영 체크리스트
- 락 키 TTL이 너무 길면 장애 유발
- 조기 만료 확률/시간은 트래픽 패턴에 맞게 튜닝
- 핫 키는 별도 모니터링 (Top N 키, hit/miss율)
4) 실무 설계 팁
- 락 없는 캐시 재생성은 소규모 트래픽에만 허용
- 락 + 조기만료 조합이 가장 현실적
- Redis 장애 대비를 위해 fallback(DB 타임아웃/서킷브레이커)도 필요
요약
- Cache Stampede는 TTL 만료 시점의 동시 재생성 폭발이다.
- 분산 락, 조기 만료, 이중 캐시는 서로 보완 관계다.
- 락 해제는 반드시 소유자 토큰 검증으로 안전하게 처리해야 한다.
연습(추천)
- 인기 키의 TTL을 5초로 줄여 스탬피드 상황을 재현해보기
- 조기 만료 확률을 바꿔가며 DB QPS 변화를 측정해보기
- 로컬 캐시(L1) 유무에 따라 Redis 부하 차이를 비교해보기
💬 댓글