View

728x90
반응형

TTL 만료 1ms 사이에 DB로 1000건이 몰리는 redis 캐시 stampede 상황은 누구나 한 번쯤 겪어봤을 것이다. 이를 막는 패턴은 단일 프로세스용 single-flight, 확률적 사전 갱신 XFetch, Redis SET NX EX 기반 분산 lock 이렇게 셋이 표준인데, 실제로 어느 것이 얼마나 효과가 있는지 한국어 자료에는 숫자가 거의 없다. 그래서 직접 golang 1.22 환경에서 동시성 100/500/1000으로 10라운드씩 네 패턴을 돌려서 p99/p999 꼬리 지연까지 비교했다. 결론부터 말하면 DB 호출 수렴 효과는 셋 다 거의 동일하지만, 꼬리 지연과 lock 누적 대기 시간에서 갈림길이 명확하게 나뉜다.

 

Redis Stampede가 무엇이며 왜 그렇게 큰 문제인가

Redis Stampede 현상

 

캐시 stampede(쇄도 현상, thundering herd라고도 부른다)는 인기 키의 TTL이 만료되는 순간 수백~수천 건의 동시 요청이 한꺼번에 백엔드로 직행해서 짧은 시간에 시스템 전체를 압박하는 패턴이다. 평소에는 캐시 히트율 99%로 잘 돌아가다가 TTL 만료 직후 1ms 사이에 DB로 1000건이 그대로 꽂히는 것 — 이것이 redis 캐시 stampede의 본질이다.

 

문제는 단순히 "DB가 한 번 더 일하는" 수준이 아니다. 백엔드 응답이 100ms 걸린다면 그 100ms 동안 들어온 요청 전부가 캐시 미스를 보고 또 백엔드를 때린다. 결과적으로 DB가 같은 키를 1000번 조회하고, 1000개의 응답이 한꺼번에 캐시에 set 되며, 그 사이 들어온 요청은 죄다 미스 상태로 누적된다. 인기 키가 여러 개라면 그것이 동시 다발로 일어나면서 DB 커넥션 풀이 즉시 고갈된다.

 

TTL 만료 1ms에 DB로 1000건이 몰리는 진짜 이유

 

이것이 단순히 "운 나쁜 타이밍"이 아닌 이유는, TTL이 백엔드 응답시간보다 짧으면 매 라운드마다 미스가 보장되기 때문이다. 본 실험에서도 의도적으로 TTL을 80ms, 백엔드 응답을 100ms로 잡았다. 첫 요청이 백엔드를 때리는 동안(100ms) 후속 요청 999개가 같은 키로 들어오는데, 캐시는 아직 비어있다(첫 요청이 set 하기 전까지). 따라서 999개도 모두 미스 → 모두 백엔드 직행 → 1000개의 동시 백엔드 호출이 cliff처럼 발생한다.

 

이를 막는 핵심 아이디어는 단 하나다: "같은 키에 대한 미스를 어떻게 1건으로 줄일 것인가". 패턴별로 이 문제에 답하는 방식이 다를 뿐이다.

 

직접 돌려본 실험 세팅 — 4패턴, 동시성 1000, 10라운드

 

각설하고, 벤치마크 환경부터 명확히 정리한다. 추측이 아닌 실제 돌린 숫자다.

 

config:
  ttl_ms: 80
  slow_backend_ms: 100
  rounds: 10
  concurrencies: [100, 500, 1000]
  patterns: [naive, singleflight, xfetch, distlock]
  lock_poll_ms: 2
  lock_ttl_sec: 5
  xfetch_beta: 1

 

Redis와 백엔드는 in-process 시뮬레이터로 돌려서 네트워크 지연 변수를 제거했다. 따라서 측정값에 보이는 지연은 순수하게 백엔드 처리 시간(100ms)과 lock 폴링 / single-flight 대기 같은 "동기화 비용"으로 해석하면 된다.

 

TTL을 백엔드 응답시간보다 짧게 잡은 이유는 무엇인가

 

이는 의도적인 설계다. TTL이 백엔드 응답보다 길면 첫 요청이 set 하는 동안 후속 요청은 그냥 캐시 히트로 빠진다 → stampede가 일어나지 않는다. 반대로 TTL이 응답시간보다 짧으면(80ms < 100ms) 매 라운드마다 반드시 cliff가 발생한다. 이렇게 만들어야 패턴 간 차이가 명확하게 드러난다. 실전에서도 인기 캐시 키는 TTL이 짧고 백엔드는 느리기 때문에 이 시나리오가 가장 현실적인 worst case다.

 

측정 지표는 여섯 축으로 잡았다.

 

지표 의미 봐야 하는 이유
db_calls 백엔드 도달한 요청 수 stampede 본질 — 적을수록 좋다
stampede% 미스 시 동시 백엔드 호출 비율 패턴 효과 직접 지표
p50_ms 응답 지연 중앙값 정상 상태 응답성
p99/p999_ms 꼬리 지연 분위 패턴 차별화 핵심
rps 초당 처리량 운영 환경 적합성
lock_contention_ms 누적 lock 대기 시간 distlock 숨은 비용

 

패턴 1: 아무것도 하지 않는 naive 캐시 — 베이스라인

 

가장 단순한 케이스다. 캐시 미스면 그냥 백엔드 호출 → 응답 받으면 캐시에 set. 동시성 제어 같은 것은 없다. 결과는 처참하다.

 

pattern conc reqs db_calls stampede% p99_ms p999_ms rps
naive 100 1000 1000 100.00% 101.86 101.96 494.1
naive 500 5000 5000 100.00% 101.39 101.43 2467.1
naive 1000 10000 10000 100.00% 108.14 108.28 4903.1

 

동시성 1000에서 10라운드 돌렸더니 DB 호출이 정확히 10,000건 — 요청의 100%가 백엔드까지 직행했다. 캐시가 사실상 무의미해진 것이다. 동시성에 비례해 DB 호출이 1,000 → 5,000 → 10,000으로 그대로 폭증한다. 이것이 cliff 시나리오의 디폴트 결과다.

 

 

위 그래프에서 naive만 동시성에 비례해 솟구치고 나머지 셋은 바닥에 깔리는 것이 보인다. 차이가 두 자리 수 수준이 아니라 세 자리 수 수준이다.

 

패턴 2: single-flight — 단일 프로세스의 정답

 

golang golang.org/x/sync/singleflight 패키지가 표준 구현체다. 같은 키에 대한 인플라이트 호출을 자동으로 병합해준다. 100개 고루틴이 동시에 같은 키를 조회해도 백엔드는 1번만 때리고 나머지 99개는 그 결과를 공유한다.

 

import "golang.org/x/sync/singleflight"

var sfg singleflight.Group

func GetUser(ctx context.Context, id string) (*User, error) {
    v, err, _ := sfg.Do(id, func() (interface{}, error) {
        // 캐시 확인 → 미스면 DB 호출 → 캐시 set
        return loadFromDB(ctx, id)
    })
    if err != nil {
        return nil, err
    }
    return v.(*User), nil
}

 

실험 결과는 깔끔하다. 동시성 1000에서도 라운드당 1건씩 10라운드 = DB 호출 정확히 10건. naive 대비 1000배 감소다.

 

pattern conc db_calls stampede% p99_ms p999_ms rps
singleflight 100 10 1.00% 104.12 104.15 493.8
singleflight 500 10 0.20% 101.56 101.59 2476.6
singleflight 1000 10 0.10% 101.92 102.00 4942.3

 

stampede 비율(요청 중 실제 DB까지 도달한 비율)이 동시성 1000에서 0.10%까지 떨어졌다. 이것이 single-flight의 위력이다.

 

 

p99도 101~104ms 수준으로 백엔드 응답시간(100ms)과 거의 동일하다. 추가 오버헤드가 거의 없다. 코드 변경량도 최소다 — 함수 한 번 감싸면 끝이다.

 

한계: 멀티 인스턴스에서는 그냥 무력하다

 

single-flight의 본질적 한계는 프로세스 경계를 넘지 못한다는 것이다. 같은 서비스 인스턴스 10개가 떠 있으면 각 인스턴스가 자기 안에서만 인플라이트 병합을 한다. 인스턴스 10개 × 라운드 10번 = DB 호출 100건이 되는 것이다. 인스턴스 100개라면 1000건이다. 결국 단일 프로세스(또는 정말 적은 수의 인스턴스)에서만 답이 된다.

 

패턴 3: XFetch — 만료 직전에 미리 새로고침하는 확률적 트릭

 

XFetch는 Vattani et al.의 "Optimal Probabilistic Cache Stampede Prevention" 논문(VLDB 2015)에서 제안된 알고리즘이다. 핵심 아이디어는 "TTL이 만료되기 전에 일부 요청이 확률적으로 미리 갱신하게 만들어서 만료 순간의 cliff를 평탄화한다"이다.

 

수식 한 줄로 요약하면 다음과 같다.

 

should_refresh = (now - delta * beta * ln(rand())) >= expiry

 

여기서 delta는 백엔드 응답시간 추정치, beta는 적극성 계수(보통 1.0)다. 만료 직전일수록 갱신 확률이 높아져서 한 요청이 "내가 미리 갱신하겠다" 하고 백엔드를 때린다. 다른 요청은 아직 살아있는 캐시값을 받는다.

 

이는 정상 분포 트래픽에서 진짜로 강력하다. 만료 순간에 1000건이 동시에 cliff를 만나는 것이 아니라, 만료 50ms 전부터 분산된 시점에 1건이 미리 갱신해주기 때문이다.

 

장점은 부드러운 부하 분산, 단점은 무엇인가

 

다만 본 실험은 cliff 시나리오다 — 워커가 한꺼번에 동시 조회하는 상황. 이런 환경에서는 XFetch의 진가가 드러나지 않는다. 결과는 실질적으로 single-flight와 동일했다.

 

pattern conc db_calls stampede% p99_ms p999_ms
xfetch 100 10 1.00% 101.53 101.55
xfetch 500 10 0.20% 101.63 101.70
xfetch 1000 10 0.10% 102.10 102.18

 

p99가 101~102ms로 single-flight보다 살짝 더 낫다(미세한 차이지만 일관된다). cliff에서는 미스 경로의 dedupe 효과가 single-flight과 거의 같이 나오기 때문이다. XFetch의 본 가치는 "만료 전에 도착하는 정상 트래픽을 흡수하는 것"인데 cliff 시나리오에는 그런 트래픽이 없어서 차별화 포인트가 사라진다. 이는 단점이라기보다 적용 환경이 다른 것이다 — 정상 분포 트래픽이라면 XFetch가 가장 부드럽다.

 

패턴 4: 분산 lock(SET NX EX) — 다중 인스턴스의 표준 카드

 

여러 인스턴스가 같은 Redis를 공유하는 환경에서는 single-flight가 막지 못한다. 그래서 Redis 자체에 분산 lock을 만드는 것이 표준 카드다. Redis SET 명령의 NX/EX 옵션을 붙여서 lock을 잡고, 잡지 못한 워커는 짧은 주기로 폴링하다가 lock을 잡은 워커가 캐시를 채우면 그 결과를 받아간다.

 

// pseudo-code
func GetUserDistLock(ctx context.Context, id string) (*User, error) {
    if v, ok := cache.Get(id); ok {
        return v, nil
    }
    lockKey := "lock:" + id
    // SET lockKey 1 NX EX 5
    locked, _ := redis.SetNX(ctx, lockKey, "1", 5*time.Second).Result()
    if locked {
        defer redis.Del(ctx, lockKey)
        u, err := loadFromDB(ctx, id)
        if err != nil { return nil, err }
        cache.Set(id, u, 80*time.Millisecond)
        return u, nil
    }
    // lock 못 잡았음 → 2ms 폴링
    for {
        time.Sleep(2 * time.Millisecond)
        if v, ok := cache.Get(id); ok {
            return v, nil
        }
    }
}

 

DB 호출 결과는 single-flight·XFetch와 동일하게 라운드당 1건으로 수렴한다.

 

pattern conc db_calls stampede% p99_ms p999_ms lock_contention_ms
distlock 100 10 1.00% 103.94 103.98 101,453.8
distlock 500 10 0.20% 104.40 104.81 505,192.0
distlock 1000 10 0.10% 104.99 105.23 1,007,795.5

 

다만 p99가 single-flight 대비 3ms, p999는 5ms 더 크다. 별 것 아닌 듯 보이지만 동시성 1000에서 일관되게 더 나쁘다.

 

 

숨은 비용: 누적 lock 대기 시간이 꼬리 지연을 만든다

 

distlock 줄의 마지막 컬럼 lock_contention_ms가 진짜 핵심이다. 동시성 1000에서 누적 1,007,795ms = 약 1,007초가 lock 대기로 깔린 것이다. 이것이 individual 요청별로 분산되면서 p99/p999를 살살 끌어올린다.

 

원리는 단순하다 — lock을 잡지 못한 999개 워커가 2ms 폴링 주기로 대기한다. 첫 폴링에서 캐시가 채워졌으면 즉시 반환되지만, 폴링 타이밍에 따라 4ms, 6ms, ... 식으로 누적된다. 동시성이 늘수록 누적 lock 대기가 선형으로 증가한다.

 

 

위 그래프를 보면 distlock만 막대가 솟아오르고 나머지는 0이다. 이것이 분산 lock의 진짜 비용이다 — DB는 보호되지만 사용자 요청은 lock 대기 시간만큼 느려진다. 폴링 주기를 1ms로 줄이면 꼬리 지연은 줄지만 Redis 부하가 늘어나고, 5ms로 늘리면 부하는 줄지만 꼬리 지연이 더 커진다. 트레이드오프다.

 

그래서 무엇을 써야 하는가? 환경별 의사결정 트리

 

네 패턴 중 절대 우월한 것이 있는가? 없다. 환경에 따라 정답이 갈린다.

 

운영 환경 추천 패턴 이유
단일 프로세스 (모놀리스) single-flight 코드 변경 최소, 오버헤드 없음, p99 가장 깔끔
다중 인스턴스 (k8s 다수 pod) 분산 lock (SETNX EX) 프로세스 경계를 넘는 유일한 옵션, 꼬리 지연 비용 감수
정상 분포 트래픽 (cliff 아님) XFetch 만료 전 트래픽 흡수 효과 극대화, 부하 평탄화
인기 키 cliff + 멀티 인스턴스 distlock + 인스턴스 내 single-flight 두 층 결합 — 인스턴스 안에서는 sf, 인스턴스 간에는 lock
TTL ≥ 백엔드 응답시간 그냥 naive로도 충분 stampede 자체가 잘 일어나지 않는다

 

실제로 큰 서비스들은 2단계 전략을 많이 쓴다. 인스턴스 내부에서 single-flight으로 1차 dedupe → 그래도 인스턴스마다 1건씩 나오는 호출은 분산 lock으로 2차 dedupe. 이렇게 하면 distlock의 lock_contention_ms도 인스턴스 수만큼만 누적되고 single-flight의 멀티 인스턴스 한계도 보완된다.

 

캐시 stampede 패턴 적용 전 챙겨야 할 운영 디테일

 

본 실험에서 다루지 않은 변수 몇 가지를 짚고 넘어간다.

 

  • lock TTL 길이: 너무 짧으면 백엔드 응답이 늦을 때 lock이 만료돼서 두 워커가 동시에 호출한다. 너무 길면 워커가 죽었을 때 다른 워커가 5초 동안 손가락을 빨고 기다린다. 백엔드 p99 + 여유분 정도가 적정하다.
  • stale-while-revalidate: 만료된 캐시값을 잠시 내주면서 백그라운드로 갱신하는 패턴이다. cliff 자체를 우회하는 강력한 옵션이지만 stale 데이터가 허용 가능한 도메인에서만 쓸 수 있다.
  • 베타값(XFetch): 1.0이 표준이지만 트래픽 특성에 따라 0.5~2.0 범위에서 튜닝의 여지가 있다. 베타를 키우면 더 일찍 갱신해서 더 부드러워지지만 백엔드 호출이 늘어난다.
  • 폴링 주기 vs pub/sub: 분산 lock 폴링 대신 lock 해제 시 Redis pub/sub으로 알리는 변형이 있다. 꼬리 지연은 줄지만 구현 복잡도 + Redis pub/sub 부하의 트레이드오프다.
  • circuit breaker: stampede 패턴들이 다 막혀도 백엔드가 진짜 죽으면 그 1건의 호출도 timeout을 누적시킨다. 외곽에 circuit breaker 한 겹을 더 두는 것이 안전하다.

 

DB 수렴은 다 되는데, 꼬리 지연이 갈림길이다

 

본 실험 조건(TTL 80ms, 백엔드 100ms, 동시성 1000, 10라운드)에서 정리하면 다음과 같다.

 

  • DB 호출 보호: single-flight, XFetch, distlock 셋 다 라운드당 1건으로 수렴한다. 차이가 없다.
  • 꼬리 지연(p99): single-flight ≈ XFetch(101~102ms) < distlock(104~105ms).
  • 숨은 비용: distlock의 lock_contention_ms가 동시성 1000에서 누적 약 1,007초 — single-flight·XFetch는 0이다.
  • 적용 환경 차이: 단일 프로세스라면 single-flight, 멀티 인스턴스라면 distlock, 정상 트래픽이라면 XFetch가 진가를 발휘한다.

 

redis 캐시 stampede를 막는 첫 단계는 "내가 처한 환경이 어디에 해당하는지" 정확히 진단하는 것이다. 모놀리스이면서 distlock을 박는 것은 오버엔지니어링이고, 멀티 인스턴스인데 single-flight만 믿는 것은 진짜 위험하다. 본 실험의 숫자는 출발점일 뿐이고, 본인 서비스의 TTL/백엔드 응답시간 비율에 맞춰 베타값·폴링 주기·lock TTL을 다시 튜닝해봐야 진짜 결론이 나온다.

 

마지막으로 한 번 더 강조하면 — 이 실험은 cliff 시나리오다. 정상 분포 트래픽에서는 XFetch가 더 빛날 수 있고, 백엔드가 200ms를 넘어가면 lock 폴링 누적이 훨씬 더 큰 비중을 차지한다. 본인 서비스의 부하 패턴을 한 번 캡처해서 동일 코드로 돌려보면 어느 패턴이 답인지 5분 안에 답이 나온다. 일반화하지 말고 직접 측정하는 것이 redis 캐시 stampede 대응의 진짜 첫 단계다.

 

그런데 Key가 서로 다르다면?

지금까지의 비교는 동일한 키 하나에 1,000개의 동시 요청이 몰리는 cliff 시나리오를 가정한 결과이다. single-flight 는 같은 키에 동시에 미스난 요청 N개를 1개로 합치는 도구이므로, 1,000개의 키가 모두 다른 경우 1,000번의 DB 호출은 그대로 발생한다. 즉 single-flight 가 책임지는 영역은 캐시 stampede 의 한 갈래일 뿐이며, 키가 분산되었음에도 백엔드가 동일한 압박을 받는 패턴은 별도의 도구로 다뤄야 한다.

키가 다른데도 stampede 와 동일한 효과가 나타나는 가장 흔한 형태는 동시 만료(snowstorm) 이다. 배포 시점에 1,000개 키를 한꺼번에 SET 하면 동일한 TTL 이 곱해져 한 시간 뒤 1,000개가 같은 순간에 만료된다. 키는 다르지만 타이밍이 같아 백엔드는 동일 키 stampede 와 구분되지 않는 압박을 받는다. 이 경우의 가장 저렴한 해결책은 TTL 지터링이다. SET 시점에 TTL 에 ±N% 의 난수 오프셋을 더해 만료 시각을 시간축 위에 분산시키면 한 시점에 몰리던 미스가 자연스럽게 평탄화된다. 코드 한 줄 변경으로 끝나는 작업이지만 효과는 본 실험의 어떤 패턴보다 직접적이다.

XFetch 가 본래의 강점을 드러내는 영역도 여기다. 본 실험의 cliff 시나리오에서는 single-flight 와 사실상 동일하게 동작했지만, 정상 트래픽이 TTL 만료 이전부터 꾸준히 도달하는 환경에서는 일부 요청이 만료 직전에 확률적으로 사전 재계산을 트리거하므로 cliff 자체가 만들어지지 않는다. TTL 지터링이 *만료 시각*을 시간축에 흩뿌리는 정적 기법이라면, XFetch 는 재계산 시각을 동적으로 흩뿌리는 기법이다.

조금 더 넓게 보면 Stale-While-Revalidate(SWR)가 있다. 만료 직전·직후의 짧은 grace 윈도 동안에는 stale 값을 즉시 반환하고, 백그라운드에서 단 한 명만(single-flight 적용) 재계산하도록 한다. 사용자는 항상 즉시 응답을 받으며 미스 경로 자체가 동기 호출이 되지 않는다. Cloudflare Workers, AWS CloudFront 같은 엣지 캐시가 기본으로 탑재한 패턴이며, 애플리케이션 캐시에서도 동일한 원리를 적용할 수 있다.

마지막으로, 서로 다른 키 N개가 동시에 hot 해지는 hot-set 패턴은 엄밀히 말하면 stampede 라기보다는 단순히 백엔드 호출량 자체가 많은 상황이다. 이 경우의 표준 도구는 batch fetch 이다. 같은 tick 안에서 발생한 N개 키 조회를 `IN (id1, id2, ...)` 같은 단일 쿼리로 묶어 DB 왕복 횟수를 N 에서 1 로 줄인다. JavaScript 진영의 `dataloader`, Java 의 `org.dataloader` 같은 라이브러리가 이 패턴을 표준화해 두었으므로 GraphQL · REST 어느 형태의 백엔드든 그대로 적용할 수 있다.

국내 IT 기업에서도 매번 다뤄지는 주제

국내 IT 기업에서도 매번 다뤄지는 주제라서 한번쯤 읽어볼만하다.

 

https://toss.tech/article/cache-traffic-tip

 

캐시 문제 해결 가이드 - DB 과부하 방지 실전 팁

대용량 트래픽 환경에서 캐시를 사용할 때 주의해야할 위험 상황과 예방법을 소개합니다.

toss.tech

 

https://channel.io/ko/team/blog/articles/tech-distributed-cache-1-67a392c5

 

Distributed Cache 도입기 (1): Redis Pub/Sub을 이용한 인터페이스 설계

채널톡의 분산 캐시 일관성 문제 해결법

channel.io

 

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31