Table of Contents
캐시 스탬피드(Cache Stampede)는 대규모 트래픽 시스템에서 가장 골치 아픈 성능 문제 중 하나입니다. 평소엔 잘 돌아가다가 특정 캐시 키가 만료되는 순간, 수천 개의 요청이 동시에 DB를 때리면서 시스템이 다운되는 현상이죠. 이 글에서는 캐시 스탬피드가 무엇인지부터 시작해서, 실서비스에서 어떻게 해결하는지까지 실전 경험을 바탕으로 낱낱이 파헤쳐 드립니다.
캐시 스탬피드가 뭔가요?
캐시 스탬피드는 수많은 요청이 동시에 만료된 캐시 키를 발견하고, 모두 동시에 같은 데이터를 재생성하려고 시도하는 현상입니다. 마치 들소 떼가 한꺼번에 몰려가는 것처럼 요청들이 우르르 몰려가서 “Thundering Herd Problem”이라고도 불립니다.
실제 사례로 이해하기
인기 상품 페이지를 생각해보세요. 매일 오전 10시에 특가 상품이 공개되고, 수만 명이 동시에 접속합니다.
# 문제가 되는 코드
def get_product_info(product_id):
cache_key = f"product:{product_id}"
# 캐시 확인
data = redis.get(cache_key)
if data is None: # 캐시 미스!
# DB에서 조회 (비용이 큼)
data = db.query(f"SELECT * FROM products WHERE id = {product_id}")
# 10분간 캐싱
redis.setex(cache_key, 600, data)
return data
평소에는 문제없이 잘 돌아갑니다. 하지만 특가 상품 페이지의 캐시가 정확히 오전 10시에 만료되면 어떻게 될까요?
- 10:00:00 - 캐시 만료
- 10:00:01 - 첫 요청이 캐시 미스를 만남
- 10:00:01.001 - 두 번째 요청도 캐시 미스 (첫 요청이 아직 캐시를 채우지 못함)
- 10:00:01.002 - 세 번째 요청도 캐시 미스 5…
- 결과: 수천 개의 요청이 동시에 DB를 강타합니다.
왜 심각한가요?
캐시를 쓰는 이유가 DB 부하를 줄이기 위해서인데, 정작 캐시 때문에 DB가 죽는 아이러니한 상황이 발생합니다.
실제 장애 사례:
- 평소 DB CPU: 20%
- 캐시 스탬피드 발생 시 DB CPU: 100% (서버 응답 불가)
- 응답 시간: 100ms → 30초 (타임아웃 발생)
- 결과: 서비스 전면 중단
왜 발생하나요?
1. 동시 캐시 만료 (Simultaneous Expiration)
모든 캐시 키를 똑같은 TTL(Time To Live)로 설정하면 문제가 됩니다.
# 위험한 패턴 - 모든 상품을 동일한 시각에 캐싱
for product_id in popular_products:
data = fetch_product(product_id)
redis.setex(f"product:{product_id}", 600, data) # 모두 10분 후 동시 만료!
2. 인기 있는 데이터 (Hot Keys)
트래픽이 특정 키에 집중되면 스탬피드 영향이 극대화됩니다. 메인 페이지 배너, 실시간 검색어 순위 등이 대표적입니다.
# 예시: 메인 페이지 데이터 (초당 10,000 요청)
main_page_data = redis.get("main:page:content")
3. 느린 재생성 시간 (Slow Regeneration)
데이터 재생성이 느릴수록 더 많은 요청이 캐시 미스를 만나게 됩니다. DB 쿼리가 복잡하거나 외부 API를 호출하는 경우 특히 취약합니다.
# 캐시 재생성이 5초 걸리면...
# 초당 1000 요청 × 5초 = 5000개 요청이 동시에 DB를 때림
def expensive_calculation(key):
time.sleep(5) # 복잡한 계산 또는 외부 API 호출
return result
해결 방법 1: Request Coalescing (요청 병합)
핵심 아이디어: 동시에 들어온 같은 요청을 하나로 병합해서, 딱 한 번만 재생성합니다.
Redis 분산 락 사용
import redis
import time
import json
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_with_coalescing(key, ttl=600, regenerate_func=None):
"""Request Coalescing을 적용한 캐시 조회"""
# 1. 캐시 확인
cached_data = redis_client.get(key)
if cached_data:
return json.loads(cached_data)
# 2. 분산 락 획득 시도
lock_key = f"lock:{key}"
# nx=True: 키가 없을 때만 설정 (락 획득)
# ex=30: 30초 후 자동 해제 (데드락 방지)
lock_acquired = redis_client.set(lock_key, "1", nx=True, ex=30)
if lock_acquired:
# 락을 획득한 요청만 재생성 (Leader)
try:
print(f" 재생성 시작: {key}")
data = regenerate_func()
# 캐시에 저장
redis_client.setex(key, ttl, json.dumps(data))
return data
finally:
# 락 해제
redis_client.delete(lock_key)
else:
# 락을 획득하지 못한 요청은 대기 (Follower)
print(f" 대기 중: {key}")
for _ in range(10): # 최대 5초 대기
time.sleep(0.5)
cached_data = redis_client.get(key)
if cached_data:
return json.loads(cached_data)
# 타임아웃 - 직접 DB 조회 (fallback)
print(f"️ 타임아웃 - fallback: {key}")
return regenerate_func()
개선 효과
Before (스탬피드 발생):
- 1000개 동시 요청 → 1000번 DB 쿼리
- DB CPU: 100%
- 평균 응답 시간: 5초
After (Request Coalescing):
- 1000개 동시 요청 → 1번 DB 쿼리
- DB CPU: 20%
- 평균 응답 시간: 100ms (락 대기 시간 포함)
해결 방법 2: Probabilistic Early Expiration (확률적 조기 만료)
핵심 아이디어: 캐시가 만료되기 전에 확률적으로 미리 재생성합니다. 만료 시간이 다가올수록 재생성 확률을 높이는 방식입니다.
import random
import time
import math
def get_with_probabilistic_expiration(key, ttl=600, delta=1.0, regenerate_func=None):
"""확률적 조기 만료를 적용한 캐시 조회
Args:
key: 캐시 키
ttl: 캐시 TTL (초)
delta: 재생성 소요 시간 (초)
regenerate_func: 데이터 재생성 함수
"""
# 캐시 데이터와 생성 시각 함께 저장
cache_data = redis_client.get(key)
if cache_data:
data = json.loads(cache_data)
created_at = data.get('_created_at', time.time())
age = time.time() - created_at
ttl_remaining = ttl - age
# 확률적 조기 재생성 판단
# 만료 시각에 가까울수록 x가 작아짐 -> -log(rand) * delta 보다 작을 확률 증가
# 즉, 만료가 임박할수록 재생성 확률 급증
if ttl_remaining - (delta * -math.log(random.random())) < 0:
print(f" 당첨! 조기 재생성: {key}")
# 재생성 로직 실행...
else:
# 현재 캐시 데이터 반환
return data['value']
#... (이하 생략)
핵심 장점:
- 캐시 만료 전에 미리미리 재생성하여 스탬피드 원천 차단
- 트래픽이 많을수록 누군가는 더 일찍 재생성하게 됨 (자동 조절)
- 별도의 락 시스템 없이 구현 가능
해결 방법 3: Serve Stale While Revalidate (우아한 갱신)
핵심 아이디어: 만료된 캐시라도 일단 사용자에게 반환하고, 백그라운드에서 조용히 재생성합니다. 사용자 경험이 가장 좋은 방식입니다.
import threading
def get_with_stale_revalidate(key, ttl=600, stale_ttl=60, regenerate_func=None):
"""Stale-While-Revalidate 패턴
Args:
key: 캐시 키
ttl: 캐시 TTL (초) - 실제 만료 시간
stale_ttl: stale 데이터를 허용하는 추가 시간 (초)
regenerate_func: 데이터 재생성 함수
"""
# 실제 캐시 키와 메타데이터 키 분리
data_key = f"data:{key}"
meta_key = f"meta:{key}" # 논리적 만료 시간 관리
# 캐시 확인
cached_data = redis_client.get(data_key)
meta = redis_client.get(meta_key)
if cached_data:
if meta:
# 신선한 캐시 (논리적 만료 전)
return json.loads(cached_data)
else:
# Stale 캐시 (논리적 만료 후, 물리적 만료 전) - 백그라운드에서 재생성
print(f" Stale 데이터 반환 + 백그라운드 재생성: {key}")
# 백그라운드 재생성 (비동기)
def background_refresh():
try:
# 분산 락을 사용하여 중복 재생성 방지 권장
new_data = regenerate_func()
redis_client.setex(data_key, ttl + stale_ttl, json.dumps(new_data))
redis_client.setex(meta_key, ttl, "fresh")
except Exception as e:
print(f" 백그라운드 재생성 실패: {e}")
thread = threading.Thread(target=background_refresh)
thread.daemon = True
thread.start()
# 일단 stale 데이터 반환 (사용자는 기다리지 않음)
return json.loads(cached_data)
# 캐시 완전 미스 (물리적 만료 후) - 동기적으로 생성
print(f" 캐시 미스 - 동기 생성: {key}")
new_data = regenerate_func()
#... (저장 로직)
return new_data
핵심 장점:
- 사용자는 항상 가장 빠른 응답을 받음 (stale이지만)
- DB 부하 분산 (백그라운드에서 천천히 재생성)
- 스탬피드 완전 방지
해결 방법 4: 다층 캐싱 (Multi-Tier Caching)
핵심 아이디어: 로컬 메모리 캐시(L1) + **Redis 원격 캐시(L2)**를 함께 사용합니다.
from cachetools import TTLCache
import threading
# 로컬 메모리 캐시 (애플리케이션 인스턴스 메모리)
local_cache = TTLCache(maxsize=1000, ttl=60) # 1분 TTL
local_cache_lock = threading.Lock()
def get_with_multi_tier(key, redis_ttl=600, regenerate_func=None):
"""다층 캐싱: 로컬 캐시 → Redis → DB"""
# L1: 로컬 메모리 캐시 확인 (가장 빠름, 네트워크 비용 0)
with local_cache_lock:
if key in local_cache:
print(f" L1 캐시 히트 (로컬): {key}")
return local_cache[key]
# L2: Redis 캐시 확인
cached_data = redis_client.get(key)
if cached_data:
print(f" L2 캐시 히트 (Redis): {key}")
data = json.loads(cached_data)
# 로컬 캐시에도 저장 (다음에 L1에서 히트하도록)
with local_cache_lock:
local_cache[key] = data
return data
# L3: DB 조회 (캐시 미스)
#... (Request Coalescing 적용하여 DB 조회)
핵심 장점:
- 로컬 캐시로 Redis 부하도 획기적으로 줄임
- Redis 장애 시에도 로컬 캐시로 일시적 방어 가능
- 초고빈도 접근 데이터(Hot Key) 처리에 탁월
해결 방법 5: TTL Jitter (캐시 만료 분산)
핵심 아이디어: 각 캐시 키마다 랜덤한 TTL을 설정해서 만료 시각을 분산시킵니다. 구현이 가장 쉽고 효과적인 Avalanche 방지책입니다.
import random
def set_cache_with_jitter(key, data, base_ttl=600, jitter_range=60):
"""TTL Jitter를 적용한 캐시 저장"""
# 랜덤 TTL 계산: base_ttl ± jitter_range
# 예: 600초 ± 60초 = 540초 ~ 660초 사이 랜덤
ttl = base_ttl + random.randint(-jitter_range, jitter_range)
# TTL이 음수가 되지 않도록 보장
ttl = max(60, ttl)
redis_client.setex(key, ttl, json.dumps(data))
작동 원리:
- Before: 100개 상품 캐시가 10:00:00에 동시 만료 → DB 폭발
- After: 100개 상품이 09:59:00 ~ 10:01:00 사이에 순차적으로 만료 → DB 부하 분산
실전 적용: 어떤 패턴을 써야 할까요?
| 상황 | 추천 패턴 | 이유 |
|---|---|---|
| 읽기 초고빈도 (Hot Key) | Multi-tier + Request Coalescing | 로컬 캐시로 Redis 부하까지 줄여야 함 |
| 데이터 재생성 비용 높음 | Request Coalescing 필수 | DB 부하 폭증 방지가 최우선 |
| 약간의 옛날 데이터 허용 | Stale-While-Revalidate | 사용자 응답 속도가 가장 중요할 때 |
| 정확성 중요 (금융 등) | Request Coalescing만 사용 | Stale 데이터 위험 회피 |
| 간단한 해결책 원함 | TTL Jitter | 코드 한 줄로 Avalanche 방지 가능 |
결론
캐시 스탬피드는 대규모 트래픽 시스템에서 반드시 해결해야 할 과제입니다. 단순히 “캐시를 더 많이 쓴다”가 해결책이 아니라, **“캐시가 없을 때 어떻게 DB를 보호할 것인가”**를 고민해야 합니다.
핵심 포인트 요약:
- Request Coalescing: 동시 요청 병합으로 DB 부하 99% 감소
- Stale-While-Revalidate: 사용자에게는 빠르고, 갱신은 조용하게
- TTL Jitter: 랜덤 TTL로 눈사태(Avalanche) 예방
여러분의 서비스 특성에 맞는 전략을 조합하여 견고한 캐싱 시스템을 구축해보세요!