캐시 스탬피드 완전 정복 — 핫 키와 TTL 동시 만료가 DB를 흔들 때

스터디·13분 읽기

캐시 스탬피드, 왜 따로 알아야 하나요?

캐시 완전 정복 글에서 캐시는 hit율, TTL, 무효화가 중요하다고 정리했습니다. 그런데 실무에서는 캐시를 붙인 뒤에도 다음과 같은 일이 반복됩니다.

  • 평소에는 빠르던 API가 특정 시각마다 갑자기 느려집니다
  • Redis miss가 늘자마자 DB CPU와 커넥션 풀이 같이 흔들립니다
  • 인기 상품, 홈 화면 블록, 랭킹 API처럼 특정 키에만 요청이 몰립니다

이런 문제의 대표적인 형태가 캐시 스탬피드(cache stampede) 입니다. 특히 요청이 많이 몰리는 핫 키(hot key) 와 만나면, 캐시는 완충재가 아니라 병목 증폭기가 될 수 있습니다.

기준: 이 글은 Redis 같은 원격 캐시와 일반적인 Cache Aside 구조를 기준으로 설명합니다. 라이브러리와 인프라에 따라 락 구현이나 stale 처리 방식은 달라질 수 있습니다.

이 글의 핵심은 세 가지 판단입니다.

  • 언제 TTL 지터만으로 충분한가
  • 언제 single flight가 필요하고, 언제 stale-while-revalidate가 더 나은가
  • 선갱신과 negative caching은 어떤 경우에만 써야 하는가

먼저 적용 순서부터 보면

운영 중인 서비스에서 캐시 스탬피드가 의심된다면, 보통은 아래 순서가 가장 안전합니다.

  1. 키별 miss율과 재생성 시간을 먼저 본다 - 어떤 키가 진짜 핫 키인지 식별합니다
  2. 전체 만료 파동부터 줄인다 - 대부분은 TTL 지터만으로도 큰 스파이크가 완화됩니다
  3. 소수 핫 키만 별도 보호한다 - single flight, per-key lock, stale-while-revalidate를 검토합니다
  4. 항상 뜨거운 공용 키만 선갱신한다 - 모든 키에 선갱신을 거는 것은 대개 과합니다

핵심은 처음부터 복잡한 락 구조를 넣지 않는 것입니다. 만료 분산 -> 핫 키 식별 -> 선택적 보호 순서가 보통 더 잘 맞습니다.

Phase 1. 캐시 스탬피드는 정확히 무엇인가?

여러 요청이 같은 시점에 같은 캐시 miss를 만나 원본 저장소로 한꺼번에 몰리는 현상

예를 들어 홈 화면 인기 상품 블록 캐시가 10분마다 만료된다고 가정해 보겠습니다.

09:59:59  cache hit
10:00:00  TTL 만료
10:00:01  요청 200개가 동시에 cache miss
10:00:01  DB 또는 원본 API로 200개 요청이 몰림
10:00:02  응답 시간 급증, 에러율 증가

핵심은 miss 자체가 아니라 같은 값을 여러 요청이 동시에 다시 만들려 한다는 점입니다.

일반적인 캐시 miss와 뭐가 다를까?

  • 일반 miss: 한두 요청이 캐시에 없어서 원본 조회 후 다시 채움
  • 캐시 스탬피드: 같은 키를 향한 다수 요청이 동시에 miss를 만나 원본 조회를 중복 수행

즉, 스탬피드는 "miss 자체"보다 "동시성" 이 핵심입니다.

핫 키는 왜 같이 언급될까?

핫 키는 하나의 캐시 키에 요청이 유난히 많이 몰리는 상태를 말합니다.

  • home:ranking
  • product:detail:123
  • event:landing:summer
  • user:profile:1 같은 매우 유명한 계정

이런 키는 평소에는 캐시 효과가 크지만, 만료되는 순간 가장 위험합니다. 같은 키를 기다리던 요청이 많기 때문입니다.

정리: 핫 키는 "요청 집중" 문제이고, 캐시 스탬피드는 "동시 miss 폭주" 문제입니다. 둘은 다른 개념이지만 실무에서는 자주 함께 나타납니다.

Phase 2. 단순한 Cache Aside 구현은 왜 쉽게 터질까?

가장 흔한 Cache Aside 코드는 보통 이런 형태입니다.

fun getPopularProducts(): PopularProductsView {
    val cacheKey = "home:popular-products"
    cache.get(cacheKey)?.let { return it }

    val result = productRepository.findPopularProducts()
    cache.put(cacheKey, result, ttl = Duration.ofMinutes(10))
    return result
}

평소에는 문제없어 보여도 TTL이 끝나는 순간에는 아래처럼 동작할 수 있습니다.

요청 A -> miss -> DB 조회
요청 B -> miss -> DB 조회
요청 C -> miss -> DB 조회
...
요청 Z -> miss -> DB 조회

즉, 캐시 하나를 다시 채우기 위해 같은 원본 조회가 중복 실행됩니다.

왜 이렇게 위험할까?

  • 비싼 SQL이 동시에 여러 번 실행됩니다
  • 외부 API라면 rate limit에 걸릴 수 있습니다
  • DB 커넥션이 짧은 시간 안에 고갈될 수 있습니다
  • 느려진 응답이 다시 재시도를 불러 더 큰 폭주로 이어질 수 있습니다

특히 원본 조회 시간이 길수록 더 위험합니다.

재생성 시간 50ms  -> 잠깐의 스파이크로 끝날 수 있음
재생성 시간 2s    -> 2초 동안 동시 요청이 계속 누적됨

결국 캐시 스탬피드는 재생성 시간이 긴 값을 동시에 다시 만드는 구조에서 커집니다.

Phase 3. 왜 TTL이 같으면 더 자주 터질까?

캐시 스탬피드는 핫 키 하나 때문에만 생기지 않습니다. 여러 키가 비슷한 시점에 함께 만료되는 구조도 흔한 원인입니다.

예를 들어 배치나 워밍업 과정에서 다음처럼 저장했다고 가정해 보겠습니다.

12:00:00 상품 상세 1번 저장, TTL 10분
12:00:00 상품 상세 2번 저장, TTL 10분
12:00:00 상품 상세 3번 저장, TTL 10분
...
12:10:00 인기 키 다수 동시 만료

이 구조에서는 사용자가 많은 시간대마다 만료 파동이 반복될 수 있습니다.

실무에서 자주 보이는 원인

  • 모든 캐시에 고정 TTL만 사용
  • 배치로 대량 적재하면서 만료 시각이 정렬됨
  • 애플리케이션 재시작 직후 워밍업한 키들이 같은 주기로 만료
  • 인기 이벤트 페이지처럼 짧은 시간 동안 특정 키 트래픽이 집중

즉, 만료는 개별 키 문제가 아니라 전체 키 스케줄링 문제가 되기도 합니다.

Phase 4. 가장 먼저 적용할 완화책: TTL 지터(jitter)

가장 단순한 첫 대응은 만료 시간을 조금씩 흔들어 놓는 것입니다.

예를 들어 10분 TTL을 고정하지 않고, 9분 30초에서 10분 30초 사이로 랜덤하게 분산합니다.

fun randomTtl(baseSeconds: Long): Duration {
    val jitter = ThreadLocalRandom.current().nextLong(-30, 31)
    return Duration.ofSeconds(baseSeconds + jitter)
}

cache.put(cacheKey, value, ttl = randomTtl(600))

핵심은 동시에 만료될 키를 시간축에서 흩뜨리는 것입니다.

장점

  • 구현이 단순합니다
  • 거의 모든 캐시 계층에서 적용 가능합니다
  • 대량 키 동시 만료 문제를 꽤 완화합니다

한계

  • 같은 키에 대한 동시 miss 자체를 막지는 못합니다
  • 재생성 시간이 긴 핫 키에는 이것만으로 부족할 수 있습니다

지터는 좋은 기본값이지만, 핫 키 하나의 동시 miss까지 막아 주지는 못합니다.

Phase 5. 핵심 완화책: 한 요청만 재생성하게 만들기

같은 키를 다시 채우는 작업은 동시에 여러 번 하지 말고, 한 번만 하자

이 접근을 흔히 single flight, request coalescing, per-key lock 같은 이름으로 부릅니다.

동작 흐름

1. 요청 A가 miss를 만난다
2. 요청 A만 재생성 권한을 얻는다
3. 요청 B, C, D는 잠깐 기다리거나 기존 stale 값을 받는다
4. 요청 A가 새 값을 캐시에 저장한다
5. 다른 요청은 새 값을 사용한다

예시: per-key lock 기반 흐름

fun getPopularProducts(): PopularProductsView {
    val cacheKey = "home:popular-products"
    cache.get(cacheKey)?.let { return it }

    val lockKey = "lock:$cacheKey"
    val token = UUID.randomUUID().toString()

    if (cache.setIfAbsent(lockKey, token, ttl = Duration.ofSeconds(5))) {
        try {
            cache.get(cacheKey)?.let { return it }

            val result = productRepository.findPopularProducts()
            cache.put(cacheKey, result, ttl = Duration.ofMinutes(10))
            return result
        } finally {
            cache.deleteIfValueMatches(lockKey, token)
        }
    }

    Thread.sleep(50)
    return cache.get(cacheKey) ?: productRepository.findPopularProducts()
}

위 코드는 개념 설명용입니다. 실제 운영에서는 아래를 함께 봐야 합니다.

  • 락 TTL이 너무 짧으면 재생성 중에 락이 풀릴 수 있습니다
  • 락 TTL이 너무 길면 장애 시 대기가 길어질 수 있습니다
  • 락 해제는 내가 잡은 락인지 확인하고 풀어야 합니다
  • 멀티 인스턴스 환경에서는 로컬 락이 아니라 공유 락 저장소가 필요할 수 있습니다
  • Thread.sleep처럼 스레드를 묶는 방식은 설명용일 뿐이고, 실제로는 짧은 backoff, 제한된 재시도, 비동기 대기 중 하나를 더 많이 씁니다

이 방식이 특히 잘 맞는 경우

  • 같은 키 재생성 비용이 큽니다
  • 핫 키 요청량이 높습니다
  • 약간 기다리더라도 중복 원본 조회를 막는 편이 낫습니다

이 방식이 과할 수 있는 경우

  • 대부분의 키가 자주 안 읽힙니다
  • 재생성 비용이 매우 작습니다
  • 락 자체의 복잡도와 운영 비용이 더 큽니다

핵심은 모든 키를 락으로 감쌀지보다, 정말 뜨거운 키 몇 개를 정확히 식별했는가입니다.

기다리게 할까, 오래된 값을 줄까?

핫 키를 보호할 때는 결국 두 가지 중 하나를 고르게 됩니다.

질문 더 잘 맞는 선택
오래된 값을 보여주면 안 되는가 single flight나 짧은 대기 기반 보호
몇 초 정도 stale 값을 보여줘도 되는가 stale-while-revalidate
재생성 비용이 크지만 요청은 기다릴 수 있는가 single flight
응답 지연보다 빠른 반환이 더 중요한가 stale-while-revalidate

두 전략의 차이는 구현 취향보다 최신성과 지연 시간 중 무엇을 더 우선하는가에 가깝습니다.

Phase 6. 기다리게 하지 않고 넘기는 방법: stale-while-revalidate

아주 잠깐 오래된 값을 보여줘도 되는 데이터라면, 요청을 세우기보다 예전 값을 먼저 주고 뒤에서 새로 고치는 방식이 낫습니다. 이것이 stale-while-revalidate 패턴입니다.

핵심 아이디어

  • fresh TTL 안에서는 정상 캐시처럼 바로 반환
  • stale 허용 구간에서는 이전 값을 반환하면서 비동기로 갱신
  • hard TTL 이후에는 더 이상 stale 값을 쓰지 않고 재생성
freshUntil = 10:00
staleUntil = 10:05

09:59 -> 최신 값 반환
10:02 -> 오래된 값 반환 + 백그라운드 갱신
10:06 -> stale도 불가, 재생성 필요

언제 잘 맞을까?

  • 홈 화면 블록
  • 인기 검색어
  • 추천 결과
  • 랭킹, 배너, 콘텐츠 큐레이션

즉, 몇 초~몇 분 정도의 지연 반영이 치명적이지 않은 읽기에 잘 맞습니다.

장점

  • 사용자 요청을 거의 막지 않습니다
  • 핫 키 재생성 타이밍의 응답 시간 급증을 완화합니다
  • 백엔드 스파이크를 줄이기 좋습니다

주의점

  • 오래된 값을 허용할 수 없는 데이터에는 맞지 않습니다
  • stale 허용 기간이 너무 길면 사실상 무효화가 느슨해집니다
  • 비동기 갱신 실패 시 재시도 정책도 함께 봐야 합니다

이 패턴은 정확히 최신일 필요는 없지만, 빠르게 보여주는 것이 더 중요한 값에 잘 맞습니다.

Phase 7. 정말 뜨거운 키는 미리 갱신할 수 있다

매우 자주 읽히는 키라면, 만료 뒤에 다시 채우기를 기다리기보다 만료 직전에 선갱신(refresh ahead) 하는 편이 낫습니다.

예를 들어 이런 식입니다.

랭킹 캐시 TTL 10분
만료 1분 전부터 비동기 갱신 예약
실제 만료 전에 새 값으로 교체

잘 맞는 대상

  • 메인 페이지 상단 블록
  • 짧은 주기로 갱신되는 랭킹
  • 트래픽이 항상 높은 공용 키

장점

  • 사용자 요청 경로에서 재생성 비용을 뺄 수 있습니다
  • 만료 순간의 스파이크를 줄이기 좋습니다

단점

  • 실제로 안 읽히는 키까지 갱신하면 낭비가 큽니다
  • 스케줄러나 워커 운영이 필요할 수 있습니다
  • 갱신 실패 감지와 fallback 흐름을 따로 설계해야 합니다

선갱신은 모든 키를 위한 기본 전략이 아니라, 정말 읽기 집중도가 높은 소수 키에만 쓰는 최적화에 가깝습니다.

Phase 8. 존재하지 않는 값도 핫 키가 될 수 있다

스탬피드는 "있는 값이 만료되는 경우"에만 생기지 않습니다. 원래부터 없는 값을 반복 조회하는 경우도 위험합니다.

예를 들어:

  • 존재하지 않는 상품 ID를 계속 조회
  • 삭제된 게시글 URL이 외부에 널리 퍼짐
  • 잘못된 사용자 ID를 봇이 계속 호출

이 경우 요청은 매번 이런 경로를 탑니다.

cache miss -> DB 조회 -> 없음 -> 응답 반환
cache miss -> DB 조회 -> 없음 -> 응답 반환
cache miss -> DB 조회 -> 없음 -> 응답 반환

이때는 negative caching 이 도움이 됩니다. "없음"도 아주 짧게 캐시하는 방식입니다.

val value = repository.findProduct(productId)
if (value == null) {
    cache.put(cacheKey, NotFoundSentinel, ttl = Duration.ofSeconds(30))
    return null
}

주의점

  • 나중에 값이 생길 수 있는 도메인이라면 TTL을 짧게 둬야 합니다
  • null과 "없음 sentinel"을 구분해야 합니다
  • 잘못된 키 폭주 자체를 막으려면 rate limiting도 함께 고려해야 합니다

즉, 핫 키는 인기 데이터만의 문제가 아니라, 반복되는 잘못된 요청에서도 생길 수 있습니다.

Phase 9. 장애 상황에서는 무엇을 먼저 볼까?

캐시 스탬피드는 로그 한 줄보다 함께 뛰는 지표 묶음으로 보는 편이 정확합니다.

자주 같이 뛰는 지표

  • 캐시 miss 비율 급증
  • 특정 키의 요청량 급증
  • 원본 DB QPS 또는 외부 API 호출 수 급증
  • 애플리케이션 p95, p99 응답 시간 상승
  • 커넥션 풀 active/pending 수 증가
  • 타임아웃, 재시도, 서킷 브레이커 open 증가

패턴으로 보면 더 명확하다

10:00:00 cache expired 증가
10:00:01 cache miss 급증
10:00:01 DB QPS 급증
10:00:02 Hikari pending 증가
10:00:03 API p99 급등

이 흐름이 보이면 "DB가 갑자기 느려졌다"보다 먼저 캐시 만료 타이밍과 핫 키 존재 여부를 의심하는 편이 맞습니다. 특히 DB 커넥션 풀 글에서 본 active/pending 증가가 같은 시각에 보인다면, 스탬피드가 커넥션 풀 고갈로 번지고 있을 가능성이 큽니다.

운영에서 특히 유용한 질문

  1. 어떤 키가 가장 많이 miss 났는가
  2. 그 키의 재생성 시간은 얼마나 걸리는가
  3. 같은 시각에 함께 만료된 키가 많았는가
  4. stale 허용이 가능한 데이터인가
  5. 락이나 single flight 없이 중복 재생성이 일어나고 있는가

Phase 10. 어떤 전략을 언제 골라야 할까?

정답은 하나가 아니라, 데이터 성격에 따라 전략을 조합하는 쪽에 가깝습니다.

상황 기본 선택
여러 키가 비슷한 시각에 만료된다 TTL 지터부터 적용
소수 핫 키의 재생성 비용이 크다 single flight 또는 per-key lock 검토
약간 오래된 값 허용 가능 stale-while-revalidate가 유리
매우 자주 읽히는 공용 키다 선갱신(refresh ahead) 검토
없는 값 조회가 반복된다 negative caching 검토

결론만 다시 압축하면 아래처럼 볼 수 있습니다.

구분 먼저 볼 선택
기본값 Cache Aside + TTL 지터
핫 키 보호 single flight 또는 stale-while-revalidate
예외 상황 선갱신, negative caching

실무에서 자주 쓰는 조합

  • 기본값: Cache Aside + TTL 지터
  • 핫 키 추가 대응: TTL 지터 + single flight
  • 사용자 체감 응답 우선: TTL 지터 + stale-while-revalidate
  • 초고빈도 공용 키: TTL 지터 + 선갱신 + 필요시 single flight

모든 키를 같은 방식으로 다루지 말고, 핫 키와 일반 키를 구분해야 합니다

캐시 전략은 보통 "기능별"보다 키의 트래픽 분포와 재생성 비용에 따라 갈립니다.

한눈에 보는 안티패턴

아래는 캐시 스탬피드를 더 쉽게 만드는 대표 패턴입니다.

  • 모든 키에 동일한 고정 TTL만 적용한다
  • 핫 키를 식별하지 않고 전부 같은 정책으로 운영한다
  • 오래된 값을 허용할 수 없는데도 TTL만 믿고 간다
  • 반대로 오래된 값을 허용해도 되는데 무조건 동기 재생성만 한다
  • 락을 도입했지만 락 만료, 중복 해제, 장애 시 복구 흐름을 설계하지 않는다

정리

  1. 실무 기본값은 Cache Aside + TTL 지터입니다 - 대부분의 만료 파동은 여기서 먼저 줄어듭니다
  2. 정말 뜨거운 키만 별도 보호합니다 - 최신성이 중요하면 single flight, 응답 속도가 더 중요하면 stale-while-revalidate를 고릅니다
  3. 선갱신과 negative caching은 선택 카드입니다 - 항상 뜨거운 공용 키나 반복되는 잘못된 요청처럼 분명한 대상이 있을 때만 씁니다