캐시 TTL jitter 설정으로 Cache Stampede 막는 실전 패턴

목차

캐시 TTL jitter는 만료 시각을 ±랜덤 오프셋만큼 흔들어서, 여러 캐시 항목이 같은 순간에 함께 죽지 않도록 분산시키는 패턴이다. Redis든 CDN이든 캐시 계층이 있고 트래픽이 몰리는 서비스라면 어디에나 붙인다. 오늘 한 줄짜리 수정을 하다가 의외로 놓치기 쉬운 포인트가 몇 개 있어서, 캐시 TTL jitter 설정을 메모 형태로 정리해둔다.

5년 넘게 같은 DB·Redis 조합을 운영하면서도 이걸 모르고 지나간 기간이 꽤 길었다. ‘잘 되면 안 건드린다’파인 쪽이라 캐시 TTL도 "3600초면 충분하지" 하고 그대로 뒀다. 그 3600이 재앙의 시작이었던 거다.

오늘 확인한 증상

대시보드에서 DB CPU가 매 정시마다 튀는 패턴이 보였다. 60% 근처에서 평범하게 깔리다가 00분에 한 번 90%대로 치솟고, 1~2분 뒤 다시 내려앉는다. p99 응답 시간도 같은 구간에서 300ms에서 1.8s로 올라갔다. 주간 그래프로 펼쳐 보면 규칙적인 톱니 모양이다.

처음엔 크론잡이나 리포트 배치가 원인일 거라 짐작했다. 스케줄러 설정을 다 뒤졌는데 정시에 도는 작업은 없었다. 로그를 긁어봐도 특정 엔드포인트의 캐시 미스가 같은 초에 수백 건씩 쌓여 있었다. 캐시 만료가 정시에 몰려 있는 전형적인 Thundering Herd였다.

원인은 허탈하다. 캐시 키 수백 개가 모두 TTL 3600으로 동일하게 박혀 있었다. 서비스가 배포된 시점, 혹은 캐시 예열 스크립트가 돌았던 시점을 기점으로 모든 키의 만료 시각이 정확히 한 시간 단위로 정렬된 셈이다. 한 번만 같이 심어지면 그때부터 모든 키가 같은 분 같은 초에 같이 죽는다.

TTL jitter를 한 줄로 설명하면

TTL을 고정값이 아니라 base + random(0, N) 또는 base ± random(0, N)으로 잡는다. 그게 전부다. 3600초짜리 TTL이면 3600 + random(0, 600) 정도면 된다. 코드로는 한 줄 수정이고, 모니터링 그래프는 즉시 달라진다.

이게 왜 먹히는가. 여러 프로세스가 같은 시각에 만료된 키를 재계산하려고 DB에 동시에 질의를 날리는 것이 Cache Stampede의 본질이다. 만료 시각을 무작위로 흩어두면, 같은 초에 만료되는 키의 밀도가 기하급수적으로 떨어진다. 균등분포 [0, N]으로 흔들면 임의 1초에 만료되는 키 수는 대략 전체/N로 수렴한다.

수치로 비교하면 대략 이런 감각이다.

설정 기준 TTL jitter 범위 같은 초에 만료될 기대치 관찰된 DB CPU 피크
고정 TTL 3600s 0 전체 키 90%+
최소 jitter 3600s ±60s 약 1/120 65% 근처
권장 jitter 3600s ±600s 약 1/1200 평상시 수준
과도한 jitter 3600s ±1800s 약 1/3600 평상시 수준이지만 hit ratio 손실

체감상 TTL의 10~20% 정도를 jitter로 잡으면 무난하다. 너무 작으면 스파이크가 남고, 너무 크면 캐시 평균 수명이 짧아져 hit ratio가 떨어진다.

Redis에서 jitter 붙이는 3가지 방법

방법 1: SET 호출부에 난수 더하기

제일 단순한 방법. 애플리케이션 코드에서 TTL을 넘기기 전에 난수를 섞는다.

import random
import redis

r = redis.Redis()

def set_with_jitter(key: str, value: str, base_ttl: int, jitter_ratio: float = 0.1):
    # base_ttl의 ±jitter_ratio만큼 흔든다
    jitter = int(base_ttl * jitter_ratio)
    ttl = base_ttl + random.randint(-jitter, jitter)
    r.setex(key, ttl, value)

# 3600초 기준 ±10% → 3240~3960초 사이 중 하나로 저장된다
set_with_jitter("user:42:profile", payload, 3600)

대부분의 경우 이걸로 끝난다. 기존 코드에 한 줄 추가하거나, setex를 감싼 공통 헬퍼를 팀 코드베이스에 하나 두고 전 서비스에서 강제로 그 함수만 쓰게 만드는 게 낫다. 개발자마다 다른 TTL 상수 하드코딩을 놓치면 jitter가 일부 키에만 적용된다.

방법 2: 정규분포로 흔들기

균등분포 대신 정규분포를 쓰면 평균 근처에 밀도가 높고 끝단은 얇게 퍼진다. hit ratio 손실을 줄이고 싶을 때 고려해볼 만하다.

import random

def normal_jitter_ttl(base_ttl: int, sigma_ratio: float = 0.05) -> int:
    # base_ttl을 중심으로 정규분포 샘플링
    ttl = random.gauss(mu=base_ttl, sigma=base_ttl * sigma_ratio)
    # 음수, 0, 지나치게 짧은 값 방지
    return max(int(ttl), base_ttl // 2)

sigma를 base의 5% 정도로 두면 약 99.7%가 ±15% 안에 들어온다. 실전에서는 과한 튜닝일 때가 많아 방법 1로 충분한 경우가 대부분이다.

방법 3: Early Recomputation (Probabilistic Early Expiration)

이건 jitter와는 방향이 약간 다른데, 같은 문제를 푸는 다른 축이라 같이 적어둔다. TTL이 만료되기 전에, 만료가 가까워질수록 확률적으로 미리 갱신한다. 알고리즘 이름은 XFetch로 알려져 있다.

import math, random, time

def should_recompute(expiry_ts: float, delta: float, beta: float = 1.0) -> bool:
    # delta: 재계산에 드는 예상 시간(초)
    # beta: 1보다 크면 더 공격적으로 미리 갱신
    now = time.time()
    return now - delta * beta * math.log(random.random()) >= expiry_ts

XFetch는 하나의 hot key가 만료될 때 그 키 하나에 대해 여러 요청이 동시에 DB를 치는 상황을 푼다. jitter는 키 간 분산, XFetch는 같은 키 내부의 요청 간 분산이다. 둘을 같이 쓰면 stampede가 거의 사라진다. 관련 원문은 "Optimal Probabilistic Cache Stampede Prevention" 논문에서 확인할 수 있다.

CloudFront·Cloudflare에서의 jitter

CDN 레이어도 같은 문제를 겪는다. Origin이 Cache-Control: max-age=3600을 고정으로 내려주면 전 세계 엣지 노드의 캐시가 같은 창문 안에서 만료된다. 글로벌 트래픽이라면 origin 쪽으로 동시 수만 건이 튀는 상황이 현실적으로 발생한다.

Origin 응답의 max-age를 살짝 흔든다

Origin 서버에서 Cache-Control을 내려줄 때 jitter를 섞는다. FastAPI 예시.

from fastapi import FastAPI, Response
import random

app = FastAPI()

@app.get("/articles/{article_id}")
def get_article(article_id: int, response: Response):
    base_max_age = 3600
    jitter = random.randint(0, 600)  # 0~10분 사이 랜덤
    response.headers["Cache-Control"] = f"public, max-age={base_max_age + jitter}"
    return fetch_article(article_id)

이 방식의 장점은 CDN 설정을 하나도 안 건드려도 된다는 것이다. Origin 응답 헤더만 수정하면 엣지 캐시가 알아서 분산된다.

CloudFront Origin Shield를 함께 켠다

CloudFront를 쓴다면 Origin Shield를 켜는 것만으로도 stampede 완화에 큰 도움이 된다. Origin Shield는 엣지 로케이션과 오리진 사이의 중간 캐시 계층이다. 여러 엣지에서 동시에 오리진을 치려 해도 Shield에서 request collapsing으로 한 번에 합쳐진다.

다만 Origin Shield는 리전 단위 요금이 붙는다. 월 트래픽과 request 수에 따라 배보다 배꼽이 클 수도 있으니, 2026-04 기준 AWS CloudFront 공식 요금 페이지에서 리전별 단가를 먼저 확인하는 편이 안전하다. (출처: AWS CloudFront Pricing, 2026년 4월 확인)

Cloudflare는 stale-while-revalidate를 같이 쓴다

Cloudflare에서는 stale-while-revalidatestale-if-error를 적극적으로 얹는 편이 낫다. max-age가 지나도 일정 시간 동안은 stale 응답을 내보내면서 백그라운드로 갱신한다. 사용자 체감 레이턴시는 그대로이고 origin 부하는 시간적으로 퍼진다.

Cache-Control: public, max-age=3600, stale-while-revalidate=600

jitter와 SWR은 배타적인 게 아니라 같이 쓴다. 엣지 캐시 만료 자체를 흩고, 만료 순간에도 stale로 버틴다. (관련 설정은 Cloudflare Cache Control 공식 문서에서 stale-while-revalidate 항목을 참고하면 된다.)

적용 후 메모와 함정

jitter를 넣고 바로 바뀐 그래프

배포 후 대시보드. 정시마다 솟던 스파이크가 편평해졌다. DB CPU 피크가 95% 근처에서 70% 안쪽으로 내려앉았고, p99도 1.8s가 사라지면서 300~400ms 박스에 머문다. 한 줄짜리 수정 치고는 효과가 과한 수준이다. 이거 왜 아무도 안 알려줘, 싶었던 순간.

함정 1: 캐시 예열 스크립트가 jitter를 무력화한다

서비스 초기에 모든 키를 한 번에 예열하는 스크립트가 있다면, jitter를 넣어도 모든 키가 비슷한 시점에 만료되는 문제가 재발한다. 예열 스크립트 자체도 키마다 TTL을 분산시켜 심어야 한다. 기존 운영 코드의 jitter 헬퍼를 그대로 예열 경로에서도 써야 한다.

함정 2: 짧은 TTL에서는 jitter 비율이 작다

TTL이 60초인데 ±6초 jitter를 넣어봐야 분산 효과는 제한적이다. 초 단위로 짧게 도는 캐시에서는 jitter보다 단일 플라이트(single-flight) 패턴으로 접근하는 편이 낫다. Go의 singleflight, Python의 asyncio.Lock, 혹은 Redis SET NX로 락 잡기.

함정 3: 관측 없이 jitter 범위를 조정할 수 없다

jitter 범위가 적절한지 확인하려면 초 단위로 만료 밀도를 찍어야 한다. Redis INFO keyspaceexpired_keys를 Prometheus exporter(redis_exporter)로 뽑고, 1초 bin으로 rate()를 그리면 피크가 보인다. 어느 정도 흩어야 충분한지는 서비스 트래픽 패턴에 따라 다르다.

당장 실행할 것

현재 운영 중인 서비스에 세 개만 먼저 확인한다. 첫째, 공통 캐시 헬퍼가 있는지. 없으면 하나 만들고 전 경로에서 강제한다. 둘째, SETEX 호출부를 grep해서 TTL 하드코딩이 여러 곳에 퍼져 있는지 본다. 있으면 헬퍼로 통일한다. 셋째, redis_exporterexpired_keys 메트릭이 Grafana에 올라와 있는지 확인한다. 안 올라와 있으면 jitter 효과를 눈으로 볼 수가 없다.

다음에는 Redis 6.2부터 추가된 EXPIRE 옵션(NX/XX/GT/LT)을 활용해서, 분산 환경에서 여러 워커가 같은 키의 TTL을 덮어쓰는 상황을 원자적으로 막는 jitter 패턴을 실험해볼 생각이다.

관련 글