[Tech Report] 캐시가 있는데 왜 DB가 터질까 — Cache Stampede의 실체와 방어 전략

Server Cache Architecture

"Redis 붙였는데 왜 DB가 터졌어요?"

캐시를 도입하면 DB 부하가 줄어드는 게 맞습니다. 그런데 특정 순간에 캐시가 오히려 DB 폭발의 방아쇠가 될 수 있습니다.

Cache Stampede(캐시 스탬피드)라고 불리는 현상입니다. 캐시를 잘못 운영하는 시스템이라면 프로모션 시작 순간, 정확히 이 패턴으로 DB가 다운됩니다.


📌 Cache Stampede란 무엇인가

캐시에 저장된 데이터가 만료(Expiry)되는 순간, 수백~수천 개의 요청이 동시에 캐시 미스를 감지하고 모두 DB로 직접 쿼리를 날리는 현상입니다.

정상 상황:
  요청 1,000개 → Redis 히트 → DB 쿼리 0번

캐시 만료 순간:
  요청 1,000개 → Redis 미스 → DB 쿼리 1,000번 동시 발생
                                ↓
                            DB 커넥션 풀 고갈 → 서비스 다운

캐시가 존재하는 상황에서 오히려 캐시가 없을 때보다 더 큰 순간 부하가 DB에 집중됩니다. 평소에는 분산되던 쿼리가 만료 시점 하나에 전부 몰리기 때문입니다.


📌 언제 발생하는가

세 가지 조건이 겹칠 때 가장 위험합니다.

조건 설명 위험도
높은 트래픽 동시 접속자가 많을수록 미스가 동시에 증폭 🔴 높음
짧은 TTL 만료 주기가 짧을수록 Stampede 빈도 증가 🔴 높음
무거운 DB 쿼리 쿼리 처리 시간이 길수록 중복 요청 누적 🔴 높음

프로모션, 이벤트 시작 시점처럼 동시 접속이 급증하는 순간이 가장 위험합니다. 평소에 문제없던 시스템이 트래픽 스파이크와 캐시 만료가 겹치는 순간 한 번에 무너집니다.


📌 해결 전략 1 — Mutex Lock (단일 재생성)

캐시 미스가 발생했을 때 단 하나의 요청만 DB를 조회하고 나머지는 대기하게 만드는 방법입니다.

import redis
import time

r = redis.Redis()

def get_with_lock(key: str, ttl: int, fetch_fn):
    # 1. 캐시 확인
    value = r.get(key)
    if value:
        return value

    lock_key = f"lock:{key}"

    # 2. 락 획득 시도 (단 하나의 요청만 성공)
    acquired = r.set(lock_key, "1", nx=True, ex=10)

    if acquired:
        try:
            # 3. DB 조회 후 캐시 저장
            value = fetch_fn()
            r.setex(key, ttl, value)
            return value
        finally:
            r.delete(lock_key)
    else:
        # 4. 락 획득 실패 → 캐시 생성될 때까지 대기
        for _ in range(10):
            time.sleep(0.1)
            value = r.get(key)
            if value:
                return value
        return fetch_fn()  # 최후 수단

DB 쿼리가 1,000번 동시 실행되던 것이 1번으로 줄어듭니다. 나머지 999개의 요청은 캐시가 채워질 때까지 짧게 대기합니다.


📌 해결 전략 2 — Probabilistic Early Expiration

만료 시점이 되기 전에 확률적으로 미리 캐시를 갱신하는 방법입니다. 락 없이 Stampede를 방지할 수 있어 성능 오버헤드가 적습니다.

import math, random, time

def get_with_early_expiration(key: str, ttl: int, beta: float, fetch_fn):
    cached = r.get(key)
    expiry = r.ttl(key)

    if cached:
        # 만료까지 남은 시간이 짧을수록 갱신 확률 상승
        # beta = 1.0이 기본값 (높을수록 더 일찍 갱신 시도)
        should_refresh = -expiry < beta * math.log(random.random())

        if not should_refresh:
            return cached

    # 캐시 갱신
    value = fetch_fn()
    r.setex(key, ttl, value)
    return value

만료 직전에 트래픽 일부가 조금씩 먼저 캐시를 갱신해두기 때문에, 모든 요청이 한꺼번에 DB를 치는 상황이 발생하지 않습니다.


📌 해결 전략 3 — TTL 랜덤 지터 (Jitter)

가장 간단하면서도 효과적인 방법입니다. 모든 캐시 키에 동일한 TTL을 설정하면 같은 시점에 일괄 만료됩니다. TTL에 무작위 값을 더해 만료 시점을 분산시킵니다.

import random

BASE_TTL = 300  # 기본 5분

def set_with_jitter(key: str, value: str, base_ttl: int = BASE_TTL):
    # ±20% 범위의 랜덤 지터 추가
    jitter = random.randint(-base_ttl // 5, base_ttl // 5)
    final_ttl = base_ttl + jitter  # 240초 ~ 360초

    r.setex(key, final_ttl, value)

# 1,000개 키가 동시에 만료되던 것이 → 분산되어 만료됨
# 초당 약 5~6개 키씩 순차 만료 → DB 부하 자연스럽게 분산

💡 Key Insight

세 전략을 조합하는 것이 가장 효과적입니다. TTL 지터로 일반적인 분산을 처리하고, Mutex Lock으로 동시 재생성을 방지하고, Early Expiration으로 핫 키를 선제적으로 갱신하세요. 하나의 전략만으로는 모든 케이스를 커버할 수 없습니다.


📌 전략별 특성 비교

전략 구현 난이도 효과 적합한 상황
TTL 지터 ⭐ 쉬움 만료 분산 모든 상황 기본 적용
Mutex Lock ⭐⭐ 보통 동시 재생성 방지 DB 쿼리가 무거울 때
Early Expiration ⭐⭐ 보통 선제적 갱신 핫 키, 고빈도 접근 데이터

📌 프로덕션 체크리스트

☑ 캐시 키 TTL에 랜덤 지터 적용 여부 확인
☑ 캐시 미스 시 단일 재생성 보장 (Mutex Lock 또는 동등 구현)
☑ 핫 키 식별 및 Early Expiration 적용
☑ 캐시 미스율 실시간 모니터링 (급등 시 알람)
☑ DB 커넥션 풀 포화 알람 설정 (Stampede 2차 감지)
☑ 트래픽 스파이크 시나리오 부하 테스트 진행
☑ 캐시 완전 장애 시 Fallback 전략 정의

마무리

캐시는 DB를 보호하는 도구입니다. 하지만 잘못 운영하면 오히려 DB 폭발의 방아쇠가 됩니다.

Cache Stampede는 "캐시를 붙였으니 안전하다"는 착각에서 시작됩니다. TTL 지터 하나만 추가해도 대부분의 Stampede를 예방할 수 있습니다. 지금 운영 중인 시스템의 캐시 TTL 설정을 한 번 확인해보시기 바랍니다.

캐시 설계, DB 커넥션 풀 최적화, 고처리량 플랫폼 아키텍처에 대한 더 자세한 내용은 아래 가이드에서 확인하실 수 있습니다.

👉 엔터프라이즈 플랫폼 아키텍처 가이드 — 고트래픽 분산 시스템 설계


PowerSoft Technical Report
Backend Architecture Series | February 2026
Author: PowerSoft R&D Center

댓글

이 블로그의 인기 게시물

[2026 Deep Dive] L7 로드밸런싱의 숨겨진 복잡성: 세션 어피니티가 만드는 트래픽 블랙홀

[Tech Review] 2,000만 트래픽도 거뜬한 2026년형 엔터프라이즈 아키텍처의 비밀

데이터베이스 교착 상태(Deadlock) 발생 원인과 해결 전략