Java 아키텍처·Spring 용어 사전
캐시thundering herd · single-flight · stale-while-revalidate

캐시 스탬피드

인기 키가 만료되는 그 순간 수천 요청이 한꺼번에 DB로 몰린다.

인기 키가 만료되는 바로 그 순간 그 키를 기다리던 요청 수천 개가 동시에 미스가 나고, 전부 DB 로 몰려가 DB 를 무너뜨리는 현상. thundering herd 라고도 한다.

평소에는 캐시가 다 받아 내던 트래픽이라 DB 는 그 부하를 감당할 준비가 되어 있지 않다. 캐시가 잘 동작할수록 만료 순간의 낙차가 커진다는 것이 고약한 점이다. 적중률 99% 라면 만료 순간 DB 부하는 순간적으로 100배가 된다.

TTL 만료 ─┬─ 요청 1 → 미스 → DB
          ├─ 요청 2 → 미스 → DB    똑같은 쿼리를
          ├─ 요청 3 → 미스 → DB    수천 번 동시에
          └─ …

여기서 낭비의 본질을 봐야 한다. 결과가 같은 계산을 수천 번 중복해서 하고 있다. 한 번만 하고 나머지가 그 결과를 나눠 쓰면 되는 일이다.

① TTL 에 지터를 준다

int ttl = 3600 + ThreadLocalRandom.current().nextInt(-300, 300);
redis.setex(key, ttl, value);

같은 시각에 채워진 키들이 같은 시각에 만료되는 것을 흩뜨린다. 가장 싸고 가장 먼저 해야 할 조치다. 다만 한 키에 요청이 집중되는 경우는 이것으로 못 막는다 — 그 키의 만료 시각은 여전히 하나다.

② single-flight — 같은 키는 한 번만 계산한다

첫 요청만 DB 로 보내고 나머지는 그 결과를 기다리게 한다. 한 대 안에서는 키 단위 락으로 되지만, 서버가 여러 대면 Redis 에 표시를 남겨야 한다.

if (redis.set(key + ":lock", id, SetParams.setParams().nx().px(3000))) {
    Object v = loadFromDb();          // 나만 DB 로 간다
    cache.put(key, v, ttl);
    redis.del(key + ":lock");
    return v;
}
// 못 잡은 쪽 — 무엇을 할지 반드시 정해야 한다

락 TTL 을 짧게 두는 이유가 중요하다. 락을 잡은 프로세스가 죽으면 그 표시가 남아 아무도 캐시를 못 채운다. TTL 이 그 교착을 자동으로 푼다. 대신 DB 조회가 TTL 보다 오래 걸리면 락이 먼저 풀려 둘째 요청도 DB 로 간다 — TTL 은 예상 조회 시간보다 넉넉해야 한다.

못 잡은 쪽의 행동도 정해야 한다. 짧게 기다렸다 캐시를 다시 보거나, 헌 값을 반환한다. 무작정 대기시키면 스레드가 묶여 DB 대신 애플리케이션이 먼저 넘어간다. 톰캣 스레드 200개가 전부 캐시를 기다리면 그 서버는 아무 요청도 못 받는다.

③ stale-while-revalidate — 헌 값을 주고 뒤에서 갱신한다

만료돼도 일단 옛 값을 반환하고 갱신은 백그라운드로 돌린다. 사용자는 항상 즉시 응답을 받고 DB 는 한 번만 맞는다. 구현은 논리적 만료로 한다 — 실제 TTL 은 길게 두고 값 안에 만료 시각을 같이 저장해, 그 시각이 지났으면 헌 값을 주면서 갱신을 띄운다.

값이 잠깐 낡아도 되는 데이터에 잘 맞는다. 더 정교하게는 TTL 이 가까워질수록 확률을 높여 만료 전에 미리 갱신하는 방식도 있다(XFetch). 만료 순간 자체를 없애는 발상이다.

면접 함정

  • "TTL 을 길게 잡으면 해결된다" → 만료는 언젠가 오고, 그때 낙차는 더 크다.
  • "락만 걸면 된다" → 락을 못 잡은 요청의 행동과 락 TTL 을 정하지 않으면 병목이 앱으로 옮겨 갈 뿐이다.
  • "지터만으로 충분하다" → 단일 인기 키 집중은 지터로 안 막힌다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 캐시·Redis — 어사이드·쓰기 전략