Python Redis TTL 캐시 설정에서 Jitter로 스탬피드 막기

목차

Python Redis TTL 캐시 설정에서 모든 키에 동일한 만료 시간을 부여하면 트래픽이 몰리는 구간에서 대규모 동시 만료가 발생한다. 결과적으로 어떤 서비스에서는 DB CPU가 30%대에서 94%까지 한 번에 치솟고 P99 지연이 평소 대비 8배 벌어지는 현상이 측정됐다.

이처럼, 처음에는 인덱스나 쿼리 플랜이 의심됐다. EXPLAIN 결과는 깨끗했다. 부하 그래프를 분 단위에서 1초 단위로 다시 그려보니 정확히 매시간 정각에 스파이크가 찍히고 있었다. 캐시 워밍 스크립트가 매시간 정각에 같은 TTL 값으로 키를 일괄 적재했고, 정확히 1시간 뒤 일제히 만료되며 부하가 한쪽으로 쏠린 거다. 의외의 원인이었다.

문제 정의 — 동시 만료가 만드는 Thundering Herd

그러나, Cache Stampede 또는 Dogpile Effect로 알려진 현상이다. 캐시가 만료되는 그 순간 동시 진입한 N개 요청이 전부 캐시 미스를 받고, 모두 같은 원본(DB나 외부 API)을 동시에 호출한다. 평소에는 캐시가 흡수하던 트래픽이 한순간 백엔드로 그대로 흘러간다.

단일 키 stampede와 대량 키 일괄 만료는 결이 다른 문제다. 단일 핫 키 stampede는 락 한 개로 막을 수 있다. 다만 수천 개 키가 같은 초에 만료되는 케이스는 락으로 해결되지 않는다. 각 키마다 락을 걸어도 갱신 작업 자체가 동시 N건이 백엔드로 들어간다. 락은 키 내부의 동시성을 줄일 뿐이지, 키 사이의 분포를 손대지 못한다.

물론, 프론트엔드에서 백엔드로 옮겨오고 가장 낯설게 느껴진 부분이 이런 시간 분포 이슈다. 브라우저 단에서는 사용자 행동이 비동기로 분산되어 발생하므로 요청이 자연스럽게 흩어진다. 서버 단에서는 스케줄러, 배치, 프리워밍이 분포를 인위적으로 만들기 때문에, 코드를 짠 사람이 분산을 의식하지 않으면 시스템 전체가 동기화된다.

기존 접근의 한계

따라서, 이 영역에서 가장 흔히 권장되는 처방은 분산 락이다. 캐시 미스가 발생하면 단일 워커만 원본을 조회하고 나머지는 대기하거나 stale 값을 반환한다. Redis의 SET key value NX EX seconds 패턴이 표준이다.

# 단일 워커만 원본 조회, 나머지는 stale 응답
acquired = redis.set(f"lock:{key}", "1", nx=True, ex=10)
if acquired:
    value = compute_expensive_data()      # 비싼 원본 조회
    redis.set(key, value, ex=3600)        # 모든 키가 같은 TTL로 들어간다
else:
    return redis.get(f"stale:{key}")      # 기존 값 폴백

단일 핫 키 시나리오에서는 잘 동작한다. 한계는 분명하다. N개 키가 같은 시점에 만료되면 N개의 워커가 각자 다른 락을 잡고 동시에 원본에 접근한다. 락은 키 내부 stampede만 막아준다.

이처럼, stale-while-revalidate(SWR) 패턴은 만료된 값을 짧은 grace 기간 동안 더 반환하면서 백그라운드로 갱신하는 전략이다. HTTP 캐시 표준 RFC 5861에서 정의된 패턴이고 Redis에도 옮길 수 있다. 데이터 신선도 요구가 낮은 영역에서는 강력하지만, 신선도가 중요한 케이스에서는 적용이 어렵다.

두 방식 모두 공통적으로 "만료 시점이 한곳에 몰린다"는 전제 자체는 바꾸지 못한다. 분포를 다루는 별도의 레이어가 필요하다.

Jitter — 분포를 깨는 가장 단순한 처방

해법은 단순하다. TTL 기본값에 무작위 오프셋을 더한다. 1시간 캐시라면 3600초 대신 3600 + uniform(-360, 360)초를 쓴다. 균등 분포 jitter다.

import random

def set_with_jitter(redis, key, value, ttl_base=3600, jitter_ratio=0.1):
    """기본 TTL의 ±jitter_ratio 범위에서 랜덤 오프셋 적용"""
    jitter = int(ttl_base * jitter_ratio)
    actual_ttl = ttl_base + random.randint(-jitter, jitter)
    redis.set(key, value, ex=actual_ttl)

결국, 만료 시점이 720초 윈도우에 균등 분포되면 같은 1초에 만료되는 키 수의 기댓값은 N/720이다. 8천 개 키 기준으로 초당 11건 정도가 동시 만료된다. 같은 시점에 8천 건이 한꺼번에 들어오던 부하가 거의 600배 분산되는 셈이다.

jitter_ratio를 어떻게 잡을지가 작은 디자인 포인트다. 너무 작으면 분산 효과가 약하고, 너무 크면 캐시 히트율이 미세하게 떨어진다. 경험적으로는 0.05~0.15 사이가 무난하다. 갱신 비용이 클수록 위쪽 값을 쓴다.

Probabilistic Early Recomputation (XFetch)

Jitter를 한 단계 발전시킨 접근이 있다. Vattani, Chierichetti, Lowenstein이 발표한 「Optimal Probabilistic Cache Stampede Prevention」 논문에서 제안한 XFetch 알고리즘이다(VLDB 2015, arXiv:1503.00498).

특히, 핵심 아이디어는 단순하다. 만료 시점이 가까워질수록 "지금 미리 갱신할까?"를 확률적으로 결정한다. 만료 직전에 호출될수록 갱신 확률이 1에 가까워진다.

import math, random, time

def xfetch_should_refresh(delta: float, expiry: float, beta: float = 1.0) -> bool:
    """
    delta: 원본 재계산에 걸리는 평균 시간(초)
    expiry: 키의 절대 만료 timestamp
    beta: 갱신 공격성. 1.0이 표준값
    """
    now = time.time()
    return now - delta * beta * math.log(random.random()) >= expiry

beta 값으로 갱신 공격성을 조절한다. beta가 클수록 더 일찍부터 확률적 사전 갱신이 발생한다. 단일 핫 키에 대해서도 stampede를 직접적으로 막아준다는 게 jitter와의 가장 큰 차이다. 논문 4장에 수학적 증명이 있는데 핵심은 "기대 stampede 횟수가 beta=1에서 최소화된다"는 결론이다.

전략 비교

물론, 세 가지 방식의 트레이드오프를 정리하면 아래와 같다.

전략 구현 난이도 stampede 방지 범위 추가 지연 적합한 케이스
분산 락 (SET NX) 낮음 단일 키 내부 락 대기 단일 핫 키 1~2개
Uniform Jitter 매우 낮음 키 간 분포 없음 대량 키 일괄 만료
XFetch 중간 단일 키 + 분포 없음 갱신 비용 큰 키
SWR 중간 양쪽 모두 없음 신선도 요구 낮은 영역

즉, 대량 키가 동시에 만료되는 게 핵심 문제라면 Uniform Jitter가 1순위다. 갱신 비용이 비싼 단일 키도 같이 보호하고 싶다면 XFetch를 추가한다. 두 방식은 충돌하지 않으니 같이 적용해도 된다.

검증 — 실제 시스템에서 본 분산 효과

실제로, 내가 운영 중인 시스템은 카탈로그 메타데이터를 Redis에 캐시한다. 키 약 8,200개, TTL 3600초 고정이었다. jitter_ratio 0.1(±360초)을 적용하기 전후로 동일 부하 패턴에서 측정했다. Redis 7.2.4, redis-py 5.0.1, Python 3.12 기준이다.

따라서, :::stats

  • 동시 만료 피크: 8,200건/초 → 19건/초 (약 430배 분산)
  • DB CPU 피크: 94% → 41%
  • API P99 지연 (피크 1분): 1,820ms → 280ms
  • 캐시 히트율 변화: 91.4% → 91.1% (0.3%p 미세 감소) :::

히트율이 약간 떨어지는 건 평균 TTL은 동일해도 분산이 커지면서 일부 키가 기댓값보다 일찍 만료되기 때문이다. 0.3%p 감소는 운영 영향이 거의 없다.

그러나, 수치 자체보다 더 인상적인 건 P99 지연 그래프 모양이 바뀌었다는 점이다. 적용 전에는 매시간 정각에 뾰족한 산이 찍혔다. 적용 후에는 그래프가 거의 평탄해졌다. 분 단위 모니터링에서는 이전에 보이던 시간당 스파이크가 사라졌고, 1초 단위로 봐도 노이즈 수준으로 줄었다.

의외였던 건 효과 크기다. 부하 테스트 결과를 처음 봤을 때 한 자릿수 개선을 예상했고, 실제로는 두 자릿수 가깝게 떨어졌다.

한계점

실제로, Jitter가 모든 케이스의 답은 아니다. 몇 가지는 신중하게 봐야 한다.

그런데, 첫 번째, 같이 만료되어야 하는 키 그룹이 있는 경우다. 사용자 프로필과 권한 정보를 따로 캐시한다면 둘이 다른 시점에 만료될 때 일시적 불일치가 노출될 수 있다. 이때는 그룹 내부에서는 같은 TTL을 공유하고, 그룹 사이에만 jitter를 적용하는 방식이 안전하다.

그래서, 두 번째, 콜드 스타트 구간에서는 jitter가 의미가 없다. 시스템이 막 시작되어 캐시가 비어 있다면 첫 트래픽은 모두 백엔드로 간다. 이 구간에는 분산 락이나 SWR이 더 효과적이다. Jitter는 정상 운영 상태에서의 분포 문제만 풀어준다.

그런데, 세 번째, Redis 메모리 정리 방식과의 상호작용이다. Redis는 만료 키를 lazy + active expiration 두 방식으로 정리한다(공식 문서 Key eviction, 2024년 기준). TTL이 분산되면 active expiration 작업도 분산된다. 이론적으로는 CPU 사용을 평탄화하는 부수 효과가 있는데, 실측에서는 차이가 노이즈 수준이었다(Redis 7.2.4 기준).

적용 가이드

당장 시도할 수 있는 액션 세 가지다.

  1. 캐시 워밍 스크립트나 배치 적재 코드에 jitter_ratio=0.1부터 넣어본다. 한두 줄 변경으로 끝난다.
  2. 백엔드 CPU와 P99 지연을 분 단위가 아니라 1~5초 단위로 본다. 분 단위 그래프에서는 stampede가 평균에 묻혀 안 보인다.
  3. 갱신 비용이 특히 큰 키(외부 API, 무거운 집계 쿼리)에는 XFetch를 따로 적용한다. redis-py 위에 헬퍼 함수를 직접 만드는 편이 추가 라이브러리를 들이는 것보다 깔끔하더라.

그러나, 다음엔 XFetch의 beta 값을 트래픽 패턴에 따라 자동 튜닝하는 방법을 실험해볼 생각이다.

관련 글