캐시의 모든 것 4편: 캐시 스탬피드 — 상황과 해법#
Cache Stampede, Penetration, Avalanche — 캐시가 오히려 장애를 일으키는 순간들
들어가며#
캐시는 강력한 성능 도구이지만, 잘못 설계하면 오히려 서버를 죽일 수 있다.
캐시가 정상 동작할 때 DB 부하의 90%를 흡수하고 있다면, 캐시가 갑자기 사라지면? DB에 10배의 트래픽이 몰린다. 단순한 성능 저하가 아니라 장애다.
이번 편에서는 캐시 관련 3대 장애 패턴과 각각의 방어법을 다룬다.
1. Cache Stampede (캐시 스탬피드)#
문제#
인기 있는 캐시 항목 하나가 만료되는 순간, 대기 중이던 수백~수천 개의 요청이 동시에 DB로 몰리는 현상이다.
평상시:
요청 1000개/초 → [Redis Hit] → 응답 (DB 부하 = 0)
캐시 만료 순간:
요청 1000개/초 → [Redis Miss!] → 1000개 모두 DB 조회
→ DB 과부하 → 응답 지연 → 타임아웃 → 장애 확산
"스탬피드(stampede)"는 소 떼가 한꺼번에 달려드는 것을 의미합니다. 정확한 비유다.
해법 1: 뮤텍스 락 (Mutex Lock)#
캐시 미스 시 한 요청만 DB를 조회하고, 나머지는 대기한다.
def get_with_lock(key):
value = redis.get(key)
if value:
return json.loads(value)
# 분산 락 획득 시도 (5초 타임아웃)
lock_key = f"lock:{key}"
if redis.set(lock_key, 1, nx=True, ex=5):
try:
# 락 획득 성공 → DB 조회
value = db.query(key)
redis.setex(key, 600, json.dumps(value))
return value
finally:
redis.delete(lock_key)
else:
# 락 획득 실패 → 잠시 대기 후 재시도
time.sleep(0.05)
return get_with_lock(key) # 재귀 호출
| 장점 | 단점 |
|---|---|
| DB에 1개 요청만 전달 | 대기 시간 발생 |
| 구현 직관적 | 데드락 위험 (타임아웃으로 방지) |
해법 2: 확률적 조기 만료 (Probabilistic Early Expiration)#
캐시가 만료되기 전에 확률적으로 미리 갱신합니다.
import math, random, time
def get_with_early_expiry(key, beta=1.0):
value, expiry, delta = redis.get_with_meta(key)
# 확률적으로 조기 갱신 여부 결정
now = time.time()
remaining = expiry - now
# remaining이 작을수록 (만료가 가까울수록) 갱신 확률 증가
if remaining - delta * beta * math.log(random.random()) <= 0:
# 조기 갱신!
value = db.query(key)
redis.setex(key, 600, json.dumps(value))
return value
원리: 만료 시간에 가까워질수록 갱신 확률이 높아져, 여러 서버 중 하나가 먼저 갱신합니다. 동시 만료를 회피한다.
이 방법은 "Optimal Probabilistic Cache Stampede Prevention" (Vattani et al., 2015) 논문에서 수학적으로 최적임이 증명되었다.
해법 3: 사전 갱신 (Eager Refresh)#
캐시 TTL의 일정 비율이 지나면 백그라운드에서 갱신합니다.
def get_with_eager_refresh(key, ttl=600, refresh_ratio=0.8):
value, remaining_ttl = redis.get_with_ttl(key)
if value and remaining_ttl < ttl * (1 - refresh_ratio):
# TTL의 80%가 지남 → 백그라운드 갱신
background_refresh(key)
return value
사용자는 항상 기존 캐시 데이터를 즉시 받고, 백그라운드에서 조용히 갱신된다.
2. Cache Penetration (캐시 관통)#
문제#
존재하지 않는 데이터를 반복 요청하면, 매번 캐시 미스 → DB 조회가 발생한다. 캐시가 아무런 보호 역할을 하지 못한다.
공격자: GET /user/9999999999 (존재하지 않는 사용자)
→ Redis: Miss
→ DB: SELECT * FROM users WHERE id = 9999999999 → 결과 없음
→ 캐시에 저장할 것도 없음
→ 다음 요청에도 또 DB 조회!
이것은 의도적인 공격(DDoS)에도 악용될 수 있다.
해법 1: Null 캐싱#
"없다"는 사실도 캐시한다.
def get_user(user_id):
cached = redis.get(f"user:{user_id}")
if cached == "NULL":
return None # 없다는 걸 캐시에서 확인
if cached:
return json.loads(cached)
user = db.query(user_id)
if user is None:
# "없음"을 짧은 TTL로 캐시 (1분)
redis.setex(f"user:{user_id}", 60, "NULL")
return None
redis.setex(f"user:{user_id}", 600, json.dumps(user))
return user
| 장점 | 단점 |
|---|---|
| 구현 간단 | 메모리 낭비 (무의미한 키가 많을 수 있음) |
| 즉시 효과 | 짧은 TTL 필요 (나중에 생성되는 경우 대비) |
해법 2: Bloom Filter#
존재 가능한 키의 집합을 확률적으로 판별한다.
from pybloom_live import BloomFilter
# 시작 시 DB의 모든 유효 키를 Bloom Filter에 등록
bf = BloomFilter(capacity=10000000, error_rate=0.001)
for key in db.get_all_keys():
bf.add(key)
def get_user(user_id):
# Bloom Filter로 사전 체크
if user_id not in bf:
return None # 확실히 없음 → DB 조회 자체를 안 함
# 있을 수도 있음 → 정상 캐시 로직
cached = redis.get(f"user:{user_id}")
if cached:
return json.loads(cached)
return db.query(user_id)
Bloom Filter 특성: - "없다" → 100% 확실 (False Negative 없음) - "있다" → 거의 확실하지만 가끔 오탐 (False Positive 가능, 0.1% 수준) - 메모리 효율: 1억 개 키를 ~120MB로 관리 가능
해법 3: 요청 검증#
애초에 유효하지 않은 요청을 입구에서 차단한다.
def get_user(user_id):
# ID 형식 검증
if not isinstance(user_id, int) or user_id < 1 or user_id > 100000000:
return None # 범위 밖 → 즉시 거부
# 정상 캐시 로직 ...
3. Cache Avalanche (캐시 눈사태)#
문제#
많은 캐시 항목이 동시에 만료되어 DB에 부하가 집중된다. 스탬피드가 "하나의 인기 키"라면, 눈사태는 "수천 개의 키가 동시에"다.
해법 1: TTL 지터 (Jitter)#
TTL에 랜덤 값을 추가하여 만료 시점을 분산한다.
import random
base_ttl = 3600 # 1시간
jitter = random.randint(0, 600) # 0~10분 랜덤
redis.setex(key, base_ttl + jitter, value)
가장 간단하고 효과적인 방법이다.
해법 2: 다단계 캐시#
캐시를 여러 계층으로 두어, 하나가 무너져도 다른 계층이 버틴다.
def get_user(user_id):
# L1: 로컬 캐시 (프로세스 메모리, 매우 빠름)
value = local_cache.get(user_id)
if value:
return value
# L2: Redis (네트워크 캐시)
value = redis.get(f"user:{user_id}")
if value:
local_cache.set(user_id, value, ttl=60) # L1에도 저장
return json.loads(value)
# L3: DB
value = db.query(user_id)
redis.setex(f"user:{user_id}", 600, json.dumps(value))
local_cache.set(user_id, value, ttl=60)
return value
해법 3: 캐시 예열 (Cache Warming)#
서버 시작/배포 시 미리 인기 데이터를 캐시에 로드한다.
def warm_cache():
"""서버 시작 시 호출"""
popular_keys = db.query("SELECT id FROM users ORDER BY access_count DESC LIMIT 10000")
for key in popular_keys:
value = db.query(key)
redis.setex(f"user:{key}", 3600 + random.randint(0, 600), json.dumps(value))
print(f"캐시 예열 완료: {len(popular_keys)}개")
4. 종합 방어 전략#
실전에서는 이 문제들이 동시에 발생할 수 있다. 종합적인 방어가 필요하다.
class RobustCache:
def __init__(self, redis_client, db_client):
self.redis = redis_client
self.db = db_client
self.bloom = BloomFilter(capacity=10000000, error_rate=0.001)
self.local_cache = LRUCache(maxsize=1000)
def get(self, key, query_fn, base_ttl=600):
# 1. 관통 방지: Bloom Filter
if key not in self.bloom:
return None
# 2. 눈사태 방지: 로컬 캐시 (L1)
value = self.local_cache.get(key)
if value:
return value
# 3. Redis 조회 (L2)
value = self.redis.get(key)
if value:
if value == b"NULL":
return None
parsed = json.loads(value)
self.local_cache.set(key, parsed, ttl=30)
return parsed
# 4. 스탬피드 방지: 뮤텍스 락
lock_key = f"lock:{key}"
if self.redis.set(lock_key, 1, nx=True, ex=5):
try:
result = query_fn(key)
if result is None:
self.redis.setex(key, 60, "NULL")
else:
# 눈사태 방지: TTL 지터
jitter = random.randint(0, int(base_ttl * 0.1))
self.redis.setex(key, base_ttl + jitter, json.dumps(result))
self.local_cache.set(key, result, ttl=30)
return result
finally:
self.redis.delete(lock_key)
else:
time.sleep(0.05)
return self.get(key, query_fn, base_ttl)
정리#
| 문제 | 원인 | 핵심 해법 |
|---|---|---|
| Stampede | 인기 키 1개 만료 | 뮤텍스 락, 확률적 조기 만료 |
| Penetration | 존재하지 않는 키 | Bloom Filter, Null 캐싱 |
| Avalanche | 대량 동시 만료 | TTL 지터, 다단계 캐시 |
실전 체크리스트:
- TTL에 지터를 추가했는가?
- 인기 키에 대한 스탬피드 방어가 있는가?
- 존재하지 않는 키에 대한 방어가 있는가?
- 서버 시작 시 캐시 예열을 하는가?
- 캐시 장애 시 DB가 버틸 수 있는가? (Graceful Degradation)
시리즈 총정리#
| 편 | 주제 | 핵심 |
|---|---|---|
| 1편 | 캐시란 무엇이고 왜 쓰는가 | 지역성 원리, 히트율, AMAT, Memory Wall |
| 2편 | CPU부터 CDN까지 | 7개 계층의 캐시, 매핑 방식, Write 정책 |
| 3편 | 캐시 교체 정책 | LRU/LFU/ARC/W-TinyLFU, 캐시 친화 코드 |
| 4편 | 캐시 스탬피드와 해법 | Stampede/Penetration/Avalanche 방어 |
캐시는 "자주 쓰는 것을 가까이 두자"라는 단순한 아이디어에서 시작하지만, 제대로 다루려면 하드웨어부터 분산 시스템까지 폭넓은 이해가 필요하다.
참고문헌#
- Vattani, A., Chierichetti, F. & Lowenstein, K. (2015). "Optimal Probabilistic Cache Stampede Prevention." PVLDB, 8(8).
- Nishtala, R. et al. (2013). "Scaling Memcache at Facebook." NSDI '13.
- Redis Documentation. "Key eviction." https://redis.io/docs/reference/eviction/
- Bloom, B. H. (1970). "Space/time trade-offs in hash coding with allowable errors." Communications of the ACM, 13(7).