Redis 캐시 스탬피드(Stampede) 방지

커버 이미지
직접 생성. 무단 사용 금지

캐시 스탬피드(Cache Stampede)란 무엇인가

캐시 스탬피드는 캐시가 만료되는 순간 다수의 요청이 동시에 원본 데이터베이스(또는 API)로 몰려가는 현상입니다. 정상 상태에서는 캐시가 요청을 흡수하지만, 캐시가 만료되면 모든 요청이 원본으로 직행하여 DB에 순간적인 과부하를 일으킵니다.

이 현상은 “Thundering Herd”라고도 불리며, 트래픽이 많은 서비스에서 장애의 주요 원인 중 하나입니다. 특히 Redis를 캐시로 사용하는 환경에서 TTL(Time To Live)이 동일한 다수의 키가 동시에 만료될 때 치명적입니다.

스탬피드가 발생하는 조건

  • 높은 동시 접속: 초당 수백~수천 건의 동일 키 조회
  • 캐시 TTL 만료: 키가 삭제되는 순간 모든 요청이 cache miss
  • 원본 조회 비용이 높음: DB 쿼리에 수백 ms 이상 소요
  • 재계산 시간 동안 보호 장치 없음: 캐시를 다시 채우는 동안 추가 요청이 계속 DB로 이동

장애 시나리오: 인기 상품 페이지의 캐시(TTL 5분) 만료 → 동시에 200개 요청이 DB에 동일 쿼리 실행 → DB 커넥션 풀 소진 → 다른 기능까지 영향 → 전체 서비스 응답 지연

패턴 1: 뮤텍스(Mutex) / 분산 락

캐시 miss가 발생하면, 하나의 요청만 DB에서 데이터를 가져오고 나머지는 대기하는 패턴입니다.

import redis
import time

r = redis.Redis()

def get_with_mutex(key, ttl=300, lock_ttl=10):
    value = r.get(key)
    if value is not None:
        return value
    
    lock_key = f"lock:{key}"
    # SET NX로 락 획득 시도
    if r.set(lock_key, "1", nx=True, ex=lock_ttl):
        try:
            # DB에서 데이터 조회
            value = fetch_from_db(key)
            r.setex(key, ttl, value)
            return value
        finally:
            r.delete(lock_key)
    else:
        # 다른 요청이 이미 DB 조회 중 → 짧은 대기 후 재시도
        time.sleep(0.05)
        return get_with_mutex(key, ttl, lock_ttl)

장점: DB에 동시에 하나의 요청만 도달하므로 부하를 확실히 줄임

단점: 대기하는 요청의 레이턴시 증가, 락 획득 실패 시 재시도 로직 필요, 락 소유자가 크래시하면 lock_ttl 만료까지 대기

패턴 2: 조기 갱신(Early Recomputation / Probabilistic Early Expiration)

캐시 TTL이 만료되기 전에 미리 갱신하는 방식입니다. 확률적 조기 만료(PER, Probabilistic Early Recomputation) 알고리즘이 대표적입니다.

import random
import math
import time

def get_with_early_recompute(key, ttl=300, beta=1.0):
    cached = r.get(key)  # {value, delta, expiry} 구조
    if cached:
        value, delta, expiry = deserialize(cached)
        # 확률적으로 TTL 만료 전에 미리 갱신
        now = time.time()
        if now - delta * beta * math.log(random.random()) < expiry:
            return value
    
    # 캐시 갱신
    start = time.time()
    value = fetch_from_db(key)
    delta = time.time() - start  # DB 조회 소요 시간
    expiry = time.time() + ttl
    r.set(key, serialize(value, delta, expiry))
    return value

원리: TTL 만료가 가까워질수록, 각 요청이 캐시를 미리 갱신할 확률이 높아집니다. beta 값이 클수록 더 일찍 갱신합니다. DB 조회 시간(delta)이 긴 키일수록 더 일찍 갱신하여, 무거운 쿼리의 스탬피드를 방지합니다.

장점: 락 없이 동작, 대부분의 요청이 대기 없이 캐시에서 응답

단점: 구현 복잡도 높음, 극단적인 동시성에서는 여전히 다수가 DB 접근 가능

패턴 3: 캐시 워밍(Cache Warming)

애플리케이션 시작 시 또는 배포 시 미리 캐시를 채워두는 방식입니다.

# 애플리케이션 시작 시 실행
def warm_cache():
    hot_keys = get_hot_keys()  # 인기 키 목록
    for key in hot_keys:
        value = fetch_from_db(key)
        r.setex(key, 300, value)
    print(f"Warmed {len(hot_keys)} keys")

# Kubernetes readiness probe와 연계
# 캐시 워밍 완료 후에만 readiness를 반환

적합한 상황:

  • 서비스 재시작/배포 후 콜드 캐시 상태에서의 스탬피드 방지
  • 인기 키 목록이 예측 가능한 경우 (인기 상품, 메인 페이지 데이터 등)

한계: TTL 만료에 의한 스탬피드는 방지하지 못함. 다른 패턴과 조합 필요

패턴 4: 이중 캐시(Stale-While-Revalidate)

캐시를 두 겹으로 운영합니다. 메인 캐시가 만료되어도 stale 캐시의 데이터를 즉시 반환하고, 백그라운드에서 메인 캐시를 갱신합니다.

def get_with_stale(key, ttl=300, stale_ttl=600):
    value = r.get(key)
    if value is not None:
        return value
    
    # 메인 캐시 miss → stale 캐시 확인
    stale_key = f"stale:{key}"
    stale_value = r.get(stale_key)
    
    if stale_value:
        # stale 데이터를 즉시 반환
        # 백그라운드에서 갱신 (별도 스레드 또는 큐)
        enqueue_refresh(key)
        return stale_value
    
    # stale도 없으면 동기적으로 DB 조회
    value = fetch_from_db(key)
    r.setex(key, ttl, value)
    r.setex(stale_key, stale_ttl, value)
    return value

장점: 사용자는 항상 빠른 응답을 받음 (stale이라도), DB 부하 최소화

단점: 일시적으로 오래된 데이터 반환 가능, 메모리 사용량 증가 (캐시 두 벌)

패턴 5: TTL 지터(Jitter)

가장 간단하면서 효과적인 방법입니다. 모든 캐시 키의 TTL에 랜덤 값을 더해 동시 만료를 방지합니다.

import random

def set_with_jitter(key, value, base_ttl=300, jitter_range=60):
    ttl = base_ttl + random.randint(0, jitter_range)
    r.setex(key, ttl, value)

원리: TTL이 300~360초로 분산되어, 동일 시점에 만료되는 키 수가 줄어듦

적합한 상황: 배치로 대량의 키를 캐싱할 때, 동일 TTL로 수천 개의 키가 동시에 만료되는 것을 방지

한계: 단일 인기 키의 스탬피드는 방지하지 못함 (해당 키의 만료 시점은 하나이므로). 다른 패턴과 반드시 조합

실무 적용 가이드

어떤 패턴을 사용할지는 서비스 특성에 따라 다릅니다.

  • 단일 인기 키 (Hot Key): 뮤텍스 + stale-while-revalidate 조합
  • 대량 키 동시 만료: TTL 지터 (필수) + 캐시 워밍
  • DB 조회가 매우 무거운 키: 조기 갱신 (PER 알고리즘)
  • 서비스 재시작 후: 캐시 워밍 + readiness probe 연계

실무에서는 대부분 여러 패턴을 조합합니다. TTL 지터는 거의 모든 경우에 기본으로 적용하고, 트래픽이 높은 키에 대해 뮤텍스나 stale 패턴을 추가하는 것이 일반적입니다.

마무리

캐시 스탬피드는 “캐시를 쓰고 있으니 괜찮겠지”라는 안심에서 시작되는 장애입니다. 캐시는 만료됩니다. 그 순간을 대비하는 것이 캐시 운영의 핵심입니다. TTL 지터부터 적용하고, 모니터링으로 hot key를 파악한 뒤, 필요한 패턴을 점진적으로 추가하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux