캐시가 만료되는 순간 모든 요청이 한꺼번에 원본으로 몰리는 현상.
-
평상시 — 초당 1만 요청 → 캐시가 다 받아 냄 → DB 는 한가 만료 순간 1만 요청이 동시에 캐시 미스
- 1만 개가 동시에 같은 쿼리를 DB 에 던진다
- DB 커넥션 고갈 → 타임아웃 → 재시도 → 더 몰림 → 붕괴
캐시가 있어서 더 위험하다 — 평소에 DB 는 그 부하를 받아 본 적이 없다
세 가지 대응
-
① 싱글플라이트(request coalescing)
- 같은 키에 대한 동시 요청 중 하나만 원본에 가고 나머지는 그 결과를 기다린다
- 1만 → 1 로 줄어든다. 가장 효과가 크다
-
② 확률적 조기 갱신
- 만료 전에 확률적으로 미리 갱신한다
- 남은 수명이 짧을수록 갱신 확률을 높인다
- 만료 시각이 흩어져 동시 미스가 사라진다
-
③ TTL 지터
- TTL 에 무작위 편차를 준다 (예: 300초 ± 30초)
- 대량 워밍업으로 만료 시각이 정렬되는 것을 막는다
싱글플라이트 구현
Map<String, CompletableFuture<V>> inflight = new ConcurrentHashMap<>();
V get(String key) {
return inflight.computeIfAbsent(key, k ->
CompletableFuture.supplyAsync(() -> loadFromDb(k)))
.whenComplete((v, e) -> inflight.remove(key))
.join();
}
Caffeine 의 LoadingCache 는 이 동작이 기본이다 같은 키의 로딩이 진행 중이면 다른 스레드는 기다린다
- 직접 만들기보다 검증된 구현을 쓰는 편이 안전하다
인접 현상 구분
- 스탬피드 — 같은 키가 동시에 만료 → 한 키로 몰림
- 캐시 관통 — 없는 키를 계속 조회 (존재하지 않는 ID 공격)
- 빈 결과도 짧게 캐시하거나 블룸 필터로 차단
- 캐시 붕괴 — 캐시 서버 자체가 죽음 → 전량이 원본으로
- 로컬 캐시 2차 계층 · 원본에 동시 요청 상한
순서를 만들지 않으면 재시도가 사태를 키운다
-
1차 붕괴 — 캐시 미스 → DB 과부하 → 타임아웃
-
2차 붕괴 — 타임아웃 → 클라이언트 재시도 → 부하 2배
-
3차 붕괴 — 재시도가 재시도를 부른다 → 회복 불가
-
재시도에는 반드시 조건을 건다
- 지수 백오프 + 지터
- 재시도 총량 제한 (retry budget)
- 서킷 브레이커로 아예 차단하고 폴백을 준다
-
"열심히 다시 시도하는 클라이언트" 가 장애를 길게 만드는 주범이다
캐시 갱신 전략과의 관계
- Cache-Aside — 미스 시 애플리케이션이 채운다 — 스탬피드에 가장 취약
- Read-Through — 캐시 계층이 채운다 — 싱글플라이트를 내장하기 쉽다 Write-Through 쓰기 시 함께 갱신 — 만료 자체가 드물어진다 Refresh-Ahead 만료 전에 미리 갱신 — 스탬피드가 구조적으로 안 생긴다
핫 키가 명확하다면 Refresh-Ahead 로 바꾸는 것이 가장 확실한 해법이다
면접 함정
- ❌ "TTL을 늘리면 해결" → 만료 순간의 집중은 그대로다.
- ❌ "캐시를 넣었으니 DB는 안전" → 미스가 몰리면 평소보다 훨씬 큰 부하가 간다.