캐시 스탬피드(cache stampede)는 인기 있는 캐시 항목이 만료되는 순간, 그 값을 찾던 수많은 요청이 동시에 캐시 미스를 겪고 한꺼번에 원본(DB)으로 몰려가 서버를 무너뜨리는 현상입니다. "썬더링 herd"라고도 부르죠. 방금까지 캐시가 다 막아주던 부하가, 만료 한 순간에 폭포처럼 DB로 쏟아집니다.
이 문제의 무서운 점은 평소엔 전혀 안 보인다는 겁니다. 캐시 적중률이 99%라 DB는 한가합니다. 그러다 딱 하나 뜨거운 키가 만료되는 그 밀리초에, 초당 수천 개의 요청이 전부 DB로 향합니다. DB는 같은 쿼리를 수천 번 중복 실행하다 쓰러지고, 그 사이 캐시는 여전히 비어 있어 뒤이은 요청도 계속 미스… 악순환이 시작됩니다.
왜 이렇게 위험한가
핵심은 중복입니다. 만료된 그 값을 계산하는 무거운 쿼리를, 필요한 결과는 하나인데 수천 개의 요청이 제각각 실행합니다. 하나만 계산해서 나눠 가지면 될 일을요. 게다가 그 쿼리가 무겁고 느릴수록(그래서 애초에 캐시했던 거죠) 창(window)이 길어져 더 많은 요청이 미스 대열에 합류합니다.
몇 번 데인 뒤에야 깨달았습니다. 캐시는 부하를 없애는 게 아니라 미루는 것이라는 걸요. 잘못 설계하면 미뤄둔 부하가 한 점에 응축돼 한꺼번에 터집니다.
해법 1 — 뮤텍스(단일 재계산)
가장 직관적인 방어입니다. 캐시 미스가 났을 때, 딱 한 요청만 락을 잡아 원본을 계산하게 하고 나머지는 잠깐 기다렸다가 갱신된 캐시를 읽게 합니다.
async function getWithLock(key) {
let val = await cache.get(key);
if (val !== null) return val;
// 이 키에 대해 락을 잡은 첫 요청만 DB로 간다
const gotLock = await cache.set(`lock:${key}`, '1',
{ NX: true, PX: 5000 }); // 5초 락
if (gotLock) {
val = await db.query(...); // 무거운 계산 1회
await cache.set(key, val, { EX: 300 });
await cache.del(`lock:${key}`);
return val;
}
// 락을 못 잡은 요청은 잠깐 대기 후 재조회
await sleep(50);
return getWithLock(key);
}
단순하고 효과적입니다. 다만 락을 못 잡은 요청들이 잠깐 대기하므로 그 순간 지연이 살짝 늘고, 락 자체가 새 경합 지점이 될 수 있습니다.
해법 2 — 확률적 조기 만료
더 우아한 방법이 있습니다. 모두가 만료 시각에 동시에 미스나는 게 문제라면, 만료되기 전에 확률적으로 미리, 그리고 제각각 갱신하게 만드는 겁니다. 이걸 확률적 조기 재계산(probabilistic early expiration)이라고 합니다.
값과 함께 "이 값을 계산하는 데 걸린 시간(delta)"을 저장해 두고, 만료가 가까워질수록 각 요청이 낮은 확률로 스스로 재계산에 나섭니다. 난수를 쓰기 때문에 재계산 시점이 자연스럽게 흩어져, 만료 순간의 동기화된 쇄도가 사라집니다.
해법 3 — 오래된 값 제공(stale-while-revalidate)
사용자 경험까지 챙기는 방식입니다. 값이 만료돼도 일단 오래된 값을 즉시 돌려주고, 백그라운드에서 조용히 갱신합니다. HTTP 캐시의 stale-while-revalidate가 바로 이 아이디어예요. 아무도 DB를 기다리지 않으니 지연이 튀지 않고, 갱신은 한 번만 일어납니다. 잠깐 낡은 데이터를 보여줘도 되는 경우(대부분의 목록·통계·랭킹)에 최고입니다.
| 방법 | 장점 | 주의점 |
|---|---|---|
| 뮤텍스 락 | 구현 단순, DB 1회 | 대기 지연·락 경합 |
| 확률적 조기 만료 | 쇄도 자체를 예방 | delta 저장·튜닝 필요 |
| stale-while-revalidate | 지연 안 튐, UX 최고 | 잠시 낡은 값 허용해야 |
덧붙이는 실전 팁
- TTL에 지터를 섞으세요. 같은 배치로 채운 캐시들이 정확히 같은 시각에 만료되면 여러 키가 동시에 스탬피드를 일으킵니다. TTL을 300초 고정 대신 270~330초로 무작위화하면 만료가 흩어집니다.
- 빈 결과도 캐시하세요. 존재하지 않는 키를 계속 조회하면 매번 DB로 갑니다(cache penetration). "없음"도 짧게 캐시해 막으세요.
자주 묻는 질문
캐시 스탬피드와 썬더링 herd는 다른 건가요?
썬더링 herd는 "하나의 자원이 풀리는 순간 다수가 동시에 몰려드는" 일반적 현상을 가리키고, 캐시 스탬피드는 그 현상이 캐시 만료에서 일어난 특수한 경우입니다. 원인과 대응이 같으므로 실무에선 거의 같은 뜻으로 씁니다.
TTL을 아주 길게 잡으면 해결되지 않나요?
스탬피드 빈도는 줄지만 데이터가 낡아지는 문제가 생기고, 결국 언젠가는 만료돼 같은 일이 벌어집니다. 만료를 없애는 게 아니라 만료 순간의 동시 쇄도를 다루는 게 핵심입니다.
인기 키 하나만 문제라면 미리 갱신(warming)하면 되나요?
좋은 전략입니다. 뜨거운 키를 알고 있다면 백그라운드 잡이 만료 전에 주기적으로 새로 채워두면 됩니다. 다만 어떤 키가 뜨거울지 예측하기 어려운 경우가 많아, 예방적 워밍과 방어적(락·stale) 기법을 함께 쓰는 게 안전합니다.
분산 환경에서 뮤텍스 락은 어떻게 거나요?
보통 Redis의 SET key value NX PX로 원자적 락을 씁니다. 위 예제가 그 방식입니다. 락에는 반드시 만료(PX)를 걸어, 락을 잡은 프로세스가 죽어도 영원히 잠기지 않게 하세요. 더 엄격한 보장이 필요하면 Redlock 같은 알고리즘을 검토합니다.

댓글 0