캐시가 만료되는 순간 DB가 무너지는 이유, 스탬피드를 막는 세 가지 방법

캐시가 만료되는 순간

이 글에서는 캐시가 만료되는 순간 DB가 무너지는 정확한 원인과, 현장에서 실제로 쓰는 방어 기법 세 가지를 코드와 함께 확인하실 수 있습니다. 핵심만 먼저 말씀드리면, 캐시 미스 요청이 한순간에 몰리는 ‘캐시 스탬피드’ 현상은 분산 락, TTL 지터, 확률적 조기 재계산 중 하나만 제대로 적용해도 크게 줄어듭니다. 다만 세 방법은 구현 난이도와 적용 조건이 서로 다르기 때문에, 트래픽 패턴과 인프라 구조에 맞춰 골라야 합니다.

캐시 스탬피드, 정확히 무엇을 가리키는 용어인가요?

캐시 스탬피드는 특정 캐시 키가 만료되는 순간, 그 키를 참조하던 다수의 요청이 동시에 캐시 미스를 겪으면서 원본 저장소(보통 DB)에 똑같은 쿼리를 동시에 날리는 현상을 말합니다. 영어로는 cache stampede 또는 thundering herd라고 부르는데, 위키백과의 스탬피드 항목에도 이 용어가 “many requests regenerating the same cached value simultaneously”로 정의되어 있습니다.

트래픽이 적을 때는 이 현상이 거의 드러나지 않습니다. 동시 요청이 몇 개뿐이면 DB가 중복 쿼리 몇 건을 받아도 금방 처리하기 때문입니다. 문제는 인기 키 하나에 초당 수백~수천 요청이 몰리는 서비스에서, 그 키가 만료되는 바로 그 순간에 전부 캐시 미스로 떨어지는 경우입니다.

캐시가 만료되는 순간 DB가 무너지는 과정

과정을 단계별로 풀어보면 이렇습니다. 평소에는 요청이 캐시에서 값을 바로 꺼내 가고, DB는 캐시 미스가 발생할 때만 간헐적으로 쿼리를 받습니다. 그런데 TTL이 정확히 같은 시각에 끝나는 키들이 많으면, 그 순간 수백 개의 요청이 “캐시에 값이 없네? 그럼 내가 DB에서 가져와서 다시 캐시에 넣어야지”라는 동일한 판단을 동시에 내립니다.

이 동일한 판단이 동시에 수백 번 반복되면 DB 커넥션 풀이 순식간에 바닥나고, 쿼리 큐가 쌓이면서 응답 시간이 늘어납니다. 응답이 늦어지면 재시도 로직이 추가 요청을 또 보내는 경우도 많아서, 한 번 시작된 스탬피드는 스스로 증폭되는 경향이 있습니다. 실무에서는 배포 직후 캐시가 전부 비어 있는 콜드 스타트 상황에서도 비슷한 패턴이 나타나는데, 이 경우는 TTL 문제가 아니라 ‘초기 적재’ 문제라서 뒤에 다룰 방법들과는 별도로 접근해야 합니다.

분산 락 · TTL 지터 · 확률적 조기 재계산 비교

트래픽 급증으로 과부하 상태에 빠진 데이터베이스 서버

분산 락으로 재계산을 하나로 묶기

캐시 미스가 나면 아무나 바로 DB를 조회하지 않고, 먼저 락을 잡은 요청 하나만 DB를 조회하고 나머지는 그 결과를 기다리거나 짧게 재시도하도록 만드는 방식입니다. Redis를 쓴다면 SET key value NX PX 조합이 가장 단순한 구현입니다. NX는 키가 없을 때만 설정하고 PX는 밀리초 단위 만료 시간을 지정하는 옵션인데, 두 옵션 모두 Redis 공식 문서의 SET 명령에 정의되어 있습니다.

import redis
import time

r = redis.Redis()

def get_or_recompute(key, ttl=60, lock_ttl=5):
    value = r.get(key)
    if value is not None:
        return value

    lock_key = f"lock:{key}"
    # 락을 선점한 요청만 True를 받습니다
    got_lock = r.set(lock_key, "1", nx=True, px=lock_ttl * 1000)

    if got_lock:
        try:
            value = query_db(key)          # 실제 DB 조회
            r.set(key, value, ex=ttl)
        finally:
            r.delete(lock_key)
        return value
    else:
        # 락을 못 잡은 요청은 잠깐 대기 후 캐시를 다시 확인
        time.sleep(0.05)
        return r.get(key) or query_db(key)

락 방식은 DB로 가는 요청을 1건으로 확실히 줄여주지만, 락을 못 잡은 요청들이 짧게라도 대기해야 해서 응답 지연이 생기고, 락을 잡은 프로세스가 비정상 종료되면 락이 남는 문제를 PX로 반드시 막아야 합니다.

TTL에 지터를 섞어 만료 시점을 흩뜨리기

여러 키의 TTL을 동일한 값으로 고정하면 만료 시점도 한 점으로 모입니다. 기준 TTL에 무작위 값을 더해 만료 시점을 분산시키면, DB로 가는 재계산 요청도 시간축에서 흩어집니다.

import random

def set_with_jitter(key, value, base_ttl=60, jitter=10):
    ttl = base_ttl + random.randint(-jitter, jitter)
    r.set(key, value, ex=ttl)

구현이 가장 단순하지만, 동시 접속자가 극단적으로 많은 핫키 하나에는 지터만으로는 부족할 수 있습니다. 지터는 ‘확률’을 낮추는 방식이라 완전히 0으로 만들지는 못합니다.

확률적 조기 재계산으로 미리 갱신하기

분산 락과 캐시 구조를 표현한 Redis 네트워크 노드 다이어그램

Vattani, Chierichetti, Lowenstein이 VLDB 2015에서 발표한 “Optimal Probabilistic Cache Stampede Prevention”에서 제안된 방식으로, 값을 계산하는 데 걸린 시간(delta)을 함께 저장해두고, 만료 시점이 다가올수록 미리 재계산을 시도할 확률을 점점 높이는 방법입니다. 다수의 요청 중 누군가가 통계적으로 먼저 재계산을 끝내 놓기 때문에, 실제 만료 순간에는 이미 새 값이 준비되어 있을 가능성이 커집니다.

import math
import random
import time

def xfetch_get(key, recompute_fn, ttl=60, beta=1.0):
    cached = r.hgetall(key)  # {value, delta, expiry}
    now = time.time()

    if cached:
        value, delta, expiry = cached[b"value"], float(cached[b"delta"]), float(cached[b"expiry"])
        xfetch = delta * beta * math.log(random.random())
        if now - xfetch < expiry:
            return value

    start = time.time()
    value = recompute_fn()
    delta = time.time() - start
    r.hset(key, mapping={"value": value, "delta": delta, "expiry": now + ttl})
    return value

세 방법을 조건 기준으로 정리하면 다음과 같습니다.

방법 구현 난이도 DB 요청 억제 효과 주요 트레이드오프
분산 락 중간 가장 확실함 (1건으로 제한) 락 대기로 응답 지연, 락 TTL 관리 필요
TTL 지터 낮음 확률적으로 완화 초고빈도 핫키에는 효과 제한적
확률적 조기 재계산 높음 꾸준히 완화, 만료 전에 선제 대응 delta 저장 등 추가 메모리·로직 필요

세 방법이 안 통하는 경우와 트레이드오프

재계산 비용이 애초에 가벼운 쿼리라면 이 세 가지 기법이 거의 필요 없습니다. DB 쿼리 한 번이 수 밀리초 안에 끝난다면, 동시에 수백 건이 몰려도 커넥션 풀이 버티는 경우가 많습니다. 반대로 집계성 쿼리나 외부 API 호출처럼 한 번의 재계산이 수백 밀리초 이상 걸리는 경우에만 스탬피드가 실제 장애로 이어집니다.

배포 직후나 캐시 서버 재시작처럼 '모든 키가 동시에 비어 있는' 상황에서는 TTL 지터나 확률적 조기 재계산이 작동하지 않습니다. 둘 다 '기존 TTL이 흐르는 도중'을 전제로 하기 때문에, 이런 콜드 스타트에는 별도로 요청 수를 제한하는 세마포어나 워밍업 스크립트를 함께 둬야 합니다. 또한 분산 락을 여러 애플리케이션 서버에서 공유하려면 Redis 같은 중앙 저장소가 필요한데, 그 Redis 자체가 느려지거나 장애가 나면 락 획득 지연이 전체 지연으로 전이된다는 점도 감안해야 합니다.

지금 코드에 바로 적용해볼 순서

가장 먼저 할 일은 모니터링으로 실제 스탬피드가 있는지 확인하는 것입니다. DB 커넥션 풀 사용량 그래프에서 특정 주기마다 뾰족한 스파이크가 보인다면, 그 시점에 만료되는 캐시 키를 역추적해보시는 걸 권장합니다. 스파이크가 확인되면 구현이 가장 쉬운 TTL 지터부터 적용하고, 그래도 스파이크가 남는 핫키에 한정해서 분산락 패턴을 추가하는 순서가 안전합니다. 재계산 비용이 특히 큰 키가 소수로 좁혀진다면, 그 키들에만 확률적 조기 재계산을 적용해 메모리 오버헤드를 최소화하는 방향이 현실적입니다.

PgBouncer 뒤에서 asyncpg가 터지는 prepared statement 충돌, 이렇게 풉니다

Leave a Comment