어느 인기 상품 페이지의 캐시 키가 TTL 만료로 사라지는 순간, 그 키를 조회하던 수천 개의 요청이 동시에 캐시 미스를 만난다. 각 요청은 저마다 “내가 캐시를 다시 채우겠다”며 일제히 데이터베이스로 몰려간다. 원래 초당 한두 번이면 충분했을 무거운 집계 쿼리가 같은 순간 수천 번 실행되고, DB 커넥션 풀이 순식간에 고갈되며, 응답이 느려지자 요청이 더 쌓이는 악순환이 시작된다. 흔히 스탬피드(cache stampede) 혹은 썬더링 허드(thundering herd)라 부르는 이 현상은, 평소 캐시로 잘 버티던 서비스가 한순간에 무너지는 대표적인 장애 시나리오다.

문제의 본질은 “캐시가 비는 그 짧은 순간, 재계산이 정확히 한 번만 일어나야 하는데 N번 일어난다”는 데 있다. 이 글에서는 스탬피드가 생기는 원리부터 실무에서 쓰이는 세 가지 대표 해법 — 뮤텍스 락, 조기 재계산, 확률적 조기 만료(XFetch) — 그리고 stale-while-revalidate 패턴과 락 구현 시 빠지기 쉬운 함정, 분산 락의 주의점까지 실전 관점에서 정리한다.

스탬피드는 왜 생기는가

스탬피드는 두 가지 상황에서 집중적으로 발생한다. 첫째는 TTL 동시 만료다. 같은 캐시 키가 하나의 TTL로 관리되면, 그 시점을 넘기는 순간 모든 요청이 동시에 미스를 겪는다. 특히 캐시를 한꺼번에 예열(warm-up)하면 여러 키의 TTL이 같은 초에 몰려 있다가 함께 터지기도 한다.

둘째는 콜드 스타트다. 배포로 캐시 노드를 재시작하거나, 캐시를 통째로 flush한 직후에는 모든 키가 비어 있다. 이때 트래픽이 몰리면 거의 모든 요청이 미스가 되어 백엔드가 한꺼번에 부하를 받는다.

핵심은 재계산 비용과 동시성의 곱이다. 재계산이 100ms 걸리는 쿼리이고 그 키에 초당 3,000 요청이 들어온다면, 캐시가 비는 100ms 동안 300개의 요청이 각자 같은 쿼리를 실행한다. 캐시의 목적이 이 중복을 없애는 것인데, 만료 순간에는 그 방어막이 완전히 사라진다.

  • 재계산이 비쌀수록(느린 쿼리·외부 API 호출) 피해가 크다
  • 키의 인기가 높을수록(핫 키) 동시 미스 규모가 커진다
  • 여러 키의 TTL이 동일 시점에 몰려 있으면 피해가 증폭된다

해법 1 — 뮤텍스 락으로 재계산을 한 번만

가장 직관적인 해법은 “재계산은 딱 한 요청만 하고, 나머지는 그 결과를 기다리거나 이전 값을 반환한다”는 것이다. Redis의 SET key value NX PX ttl은 키가 없을 때만 값을 세팅하는 원자적 연산이라 락으로 쓰기 좋다. 락을 획득한 단 하나의 요청만 DB를 치고, 나머지는 잠깐 기다렸다가 채워진 캐시를 읽는다.

import time
import uuid
import redis

r = redis.Redis()

def get_with_lock(key, recompute, ttl=300, lock_ttl=10):
    val = r.get(key)
    if val is not None:
        return val

    lock_key = f"lock:{key}"
    token = uuid.uuid4().hex  # 락 소유권 식별용 (오해제 방지)

    # 나 혼자만 재계산 권한을 얻는다
    if r.set(lock_key, token, nx=True, px=lock_ttl * 1000):
        try:
            val = recompute()          # 여기서만 DB를 친다
            r.set(key, val, ex=ttl)
            return val
        finally:
            release_lock(lock_key, token)

    # 락을 못 얻은 요청: 잠깐 기다렸다가 채워진 캐시를 읽는다
    for _ in range(50):
        time.sleep(0.02)
        val = r.get(key)
        if val is not None:
            return val
    # 그래도 없으면 최후의 수단으로 직접 계산 (락 홀더가 죽은 경우)
    return recompute()

락을 해제할 때는 반드시 내가 건 락인지 확인하고 지워야 한다. 그냥 DEL lock_key를 하면, 내 락이 이미 만료되고 다른 요청이 새로 건 락을 실수로 지워버릴 수 있다. 토큰을 비교한 뒤에만 삭제하는 원자적 처리가 필요하다.

RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
"""

_release = r.register_script(RELEASE_LUA)

def release_lock(lock_key, token):
    _release(keys=[lock_key], args=[token])

대기 대신 이전 값(stale)을 즉시 반환하는 변형도 흔하다. 값과 별개로 캐시를 조금 더 오래 살려두고, 논리적 만료 시각을 값 안에 함께 저장한다. 만료가 지났으면 락을 잡은 하나만 백그라운드로 갱신하고, 나머지는 낡았지만 유효한 값을 그대로 돌려준다. 사용자는 대기 없이 응답을 받고, DB는 한 번만 부하를 받는다.

해법 2 — 조기 재계산으로 만료 자체를 피하기

락은 “이미 만료됐을 때” 몰림을 막지만, 애초에 만료 순간을 만들지 않는 접근도 있다. TTL이 아직 남아 있을 때 미리 백그라운드에서 값을 갱신해두는 조기 재계산(early recomputation)이다. 예를 들어 TTL 300초짜리 키를 만료 30초 전에 미리 갱신하면, 사용자 요청은 항상 유효한 캐시를 만나고 재계산은 부하가 적은 시점에 조용히 처리된다.

import time, json

def get_with_early_refresh(key, recompute, ttl=300, refresh_before=30):
    raw = r.get(key)
    now = time.time()

    if raw is not None:
        entry = json.loads(raw)
        # 만료가 임박했고, 아직 아무도 갱신을 시작하지 않았다면
        if entry["expire_at"] - now < refresh_before:
            if r.set(f"refresh:{key}", "1", nx=True, ex=refresh_before):
                # 락을 잡은 요청만 백그라운드로 갱신 (여기선 동기 예시)
                _refresh(key, recompute, ttl)
        return entry["value"]

    # 콜드 스타트: 값이 아예 없으면 즉시 채운다
    return _refresh(key, recompute, ttl)["value"]

def _refresh(key, recompute, ttl):
    value = recompute()
    entry = {"value": value, "expire_at": time.time() + ttl}
    # 실제 Redis TTL은 여유를 둬서 stale 반환이 가능하게 한다
    r.set(key, json.dumps(entry), ex=ttl + 60)
    return entry

조기 재계산의 장점은 사용자 경로에서 재계산 대기가 사라진다는 점이다. 단점은 트래픽이 아주 낮은 키의 경우 “만료 임박” 구간에 요청이 안 들어오면 결국 만료돼버려, 다음 요청이 콜드 미스를 겪을 수 있다는 것이다. 그래서 완전한 해법이라기보다, 뮤텍스나 확률적 만료와 조합해 쓰는 편이 안전하다.

해법 3 — 확률적 조기 만료(XFetch)

조기 재계산의 “만료 몇 초 전”이라는 고정 임계값에는 약점이 있다. 임계값 구간에 여러 요청이 동시에 들어오면 그들끼리 또 몰릴 수 있고, 임계값을 넓히면 불필요한 갱신이 늘어난다. 이를 확률적으로 부드럽게 푼 것이 XFetch(확률적 조기 만료) 알고리즘이다.

아이디어는 이렇다. 값을 계산할 때 걸린 시간 delta를 함께 저장해두고, 각 요청은 다음 값을 계산해 그것이 0보다 크면 “지금 미리 갱신하라”고 판단한다.

import math, random, time, json

def get_with_xfetch(key, recompute, ttl=300, beta=1.0):
    raw = r.get(key)
    now = time.time()

    if raw is not None:
        entry = json.loads(raw)
        # -delta * beta * ln(random) 이 남은 수명을 넘으면 미리 갱신
        gap = entry["delta"] * beta * -math.log(random.random())
        if now - gap < entry["expire_at"]:
            return entry["value"]     # 아직은 그대로 사용
        # 확률적으로 조기 갱신 대상으로 선정됨
        if not r.set(f"xf:{key}", "1", nx=True, ex=int(entry["delta"]) + 1):
            return entry["value"]     # 다른 요청이 이미 갱신 중이면 stale 반환

    start = time.time()
    value = recompute()
    delta = time.time() - start       # 재계산에 걸린 시간
    entry = {"value": value, "delta": delta, "expire_at": now + ttl}
    r.set(key, json.dumps(entry), ex=ttl + 60)
    return value

핵심은 -delta * beta * log(random)이라는 식이다. random()은 0~1 사이 난수이므로 -log(random)은 대부분 작은 값이지만 가끔 큰 값이 나온다. 즉 만료가 가까울수록, 그리고 재계산이 비쌀수록(delta가 클수록) 요청이 “미리 갱신하자”고 판단할 확률이 높아진다. 갱신 시점이 만료 전 구간에 확률적으로 흩어지므로 특정 순간에 몰리지 않는다. beta는 공격성 조절 파라미터로, 1보다 크면 더 일찍·자주 갱신하고 작으면 만료에 더 가까워질 때까지 미룬다.

stale-while-revalidate와 함께 쓰기

위 세 해법의 공통 밑바탕은 stale-while-revalidate 패턴이다. HTTP 캐시 헤더에도 있는 이 개념은 “낡은 값이라도 즉시 응답하고, 갱신은 뒤에서 처리한다”는 것이다. 논리적 만료 시각과 실제 Redis TTL을 분리해두면 이 패턴을 자연스럽게 구현할 수 있다.

  • 논리적 만료: 값 안에 담아둔 expire_at. 이 시각이 지나면 “갱신이 필요한 상태”로 본다.
  • 물리적 TTL: Redis 실제 만료. 논리적 만료보다 넉넉히 길게 잡아, 갱신 중에도 stale 값을 반환할 여유를 준다.

이렇게 하면 만료가 지나도 값이 즉시 사라지지 않으므로, 갱신을 담당하는 하나의 요청을 제외한 나머지는 대기 없이 낡은 값을 받는다. 사용자 체감 지연이 없고, 백엔드는 갱신 한 번만 감당한다. 단, 낡은 값을 잠시 보여주는 것이 허용되는 데이터에만 써야 한다. 재고 수량이나 결제 잔액처럼 정확성이 중요한 값에는 부적절하다.

락을 원자적으로: Lua로 get-or-lock

애플리케이션 코드에서 GETSET NX를 따로 호출하면, 그 사이에 다른 요청이 끼어드는 경쟁 구간이 생긴다. 조회와 락 획득을 한 번의 왕복으로 원자적으로 처리하려면 Lua 스크립트가 깔끔하다. 값이 있으면 값을 돌려주고, 없으면 락을 시도해 “내가 재계산하라”는 신호를 함께 반환한다.

-- KEYS[1] = 캐시 키, KEYS[2] = 락 키
-- ARGV[1] = 락 토큰, ARGV[2] = 락 TTL(ms)
-- 반환: {"HIT", value} | {"LOCKED", token} | {"WAIT"}
local val = redis.call('GET', KEYS[1])
if val then
    return {'HIT', val}
end
local ok = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', tonumber(ARGV[2]))
if ok then
    return {'LOCKED', ARGV[1]}   -- 이 요청이 재계산 담당
else
    return {'WAIT'}              -- 다른 요청이 재계산 중, 대기하라
end

애플리케이션은 반환값에 따라 분기한다. HIT이면 그대로 사용하고, LOCKED면 재계산 후 캐시를 채우고 토큰으로 락을 해제하며, WAIT이면 짧게 폴링하며 캐시가 채워지길 기다린다. 스크립트가 서버에서 원자적으로 실행되므로 조회와 락 사이의 경쟁이 원천 차단된다.

락 구현의 함정과 분산 락 주의점

뮤텍스 락은 만능이 아니다. 실무에서 자주 밟는 지뢰들을 정리하면 다음과 같다.

  • 락 TTL이 너무 짧으면: 재계산이 락 TTL보다 오래 걸리면 락이 먼저 풀려버린다. 그 사이 다른 요청이 락을 잡아 이중 재계산이 일어나고, 원래 홀더가 뒤늦게 남의 락을 해제하는 사고로 이어진다. 토큰 비교 해제가 이래서 필수다.
  • 락 TTL이 너무 길면: 락 홀더가 재계산 도중 죽으면(프로세스 크래시·타임아웃), TTL이 만료될 때까지 아무도 갱신하지 못한다. 그 사이 캐시도 비어 있으면 오히려 스탬피드가 지속된다. TTL은 재계산 최대 소요보다 약간만 길게 잡는 것이 좋다.
  • 대기 요청의 폭주: 락을 못 얻은 요청이 무한정 폴링하면 그 자체로 부하다. 최대 대기 횟수를 두고, 초과하면 stale 반환이나 에러로 빠르게 실패시켜야 한다.
  • 단일 실패점: 락을 관리하는 Redis 자체가 죽으면 락 로직 전체가 무력화된다. 캐시가 죽었을 때 백엔드로 직접 폴백하는 경로에도 별도의 동시성 제한(세마포어 등)을 두는 것이 안전하다.

여러 Redis 노드에 걸친 분산 락(Redlock)은 논쟁적이다. 단일 인스턴스의 SET NX PX는 “성능 최적화용 락”으로는 충분하지만, 여러 노드에 락을 나눠 거는 Redlock이 안전성을 보장하는지에 대해서는 유명한 반론이 있다. GC 일시정지나 시계 오차로 락 홀더가 자신이 여전히 락을 쥐고 있다고 착각하는 상황을 완벽히 막지 못한다는 지적이다. 다행히 캐시 스탬피드 방지는 정확성이 아니라 효율성의 문제다. 락이 아주 드물게 두 번 잡혀 재계산이 두 번 일어나도, 그저 DB를 두 번 칠 뿐 데이터가 깨지지는 않는다. 그러니 이 용도에는 단일 인스턴스의 단순한 락으로 충분하며, Redlock 같은 무거운 장치까지 동원할 필요는 없다.

마무리

캐시 스탬피드의 본질은 “캐시가 비는 순간 재계산이 N번 일어난다”는 데 있다. 이를 막는 방법은 크게 두 방향이다. 하나는 재계산을 한 번으로 제한하는 것(뮤텍스 락, Lua get-or-lock)이고, 다른 하나는 만료 순간 자체를 만들지 않는 것(조기 재계산, 확률적 조기 만료)이다. 그리고 이 모두를 부드럽게 이어주는 것이 논리적 만료와 물리적 TTL을 분리하는 stale-while-revalidate 패턴이다. 실무에서는 이들을 조합해, 핫 키에는 확률적 조기 만료로 몰림을 흩고, 만약을 대비해 뮤텍스로 최종 방어선을 두는 식으로 쓴다. 스탬피드 방지는 정확성이 아니라 효율성의 문제라는 점을 기억하면, 과도하게 복잡한 분산 락에 빠지지 않고 서비스 특성에 맞는 단순한 해법을 고를 수 있다.

자주 묻는 질문

Q. TTL에 무작위 지터(jitter)만 줘도 스탬피드를 막을 수 있나요?
A. 여러 키의 TTL이 같은 시점에 몰리는 문제(예: 일괄 예열)에는 지터가 효과적입니다. TTL을 300초 고정 대신 300±30초처럼 흩어주면 동시 만료가 완화됩니다. 다만 지터는 서로 다른 키들의 만료를 분산할 뿐, 하나의 인기 키에 요청이 몰리는 상황은 막지 못합니다. 핫 키 하나가 만료되는 순간의 몰림은 결국 락이나 확률적 조기 만료로 다뤄야 합니다. 지터는 저비용 1차 방어로 두고, 그 위에 다른 해법을 얹는 것이 좋습니다.

Q. stale 값을 반환하면 사용자에게 낡은 데이터를 보여주는 건데 괜찮나요?
A. 데이터 성격에 달렸습니다. 상품 설명, 게시글 목록, 통계 대시보드처럼 몇 초 낡아도 문제없는 값에는 stale 반환이 최선의 절충입니다. 사용자는 지연 없이 응답을 받고 갱신은 뒤에서 일어납니다. 반면 재고 수량, 계좌 잔액, 권한 정보처럼 정확성이 중요한 값에는 stale 반환을 쓰면 안 됩니다. 이런 데이터에는 락으로 재계산을 기다리게 하거나, 애초에 이 방식의 캐싱 대상에서 제외하는 편이 안전합니다.

Q. 확률적 조기 만료(XFetch)와 뮤텍스 락 중 하나만 골라야 한다면요?
A. 트래픽이 꾸준한 핫 키가 주 관심사라면 XFetch가 우아합니다. 갱신을 만료 전 구간에 확률적으로 흩어 특정 순간의 몰림을 애초에 줄이고, 사용자 경로에 대기를 만들지 않기 때문입니다. 반면 콜드 스타트나 트래픽이 불규칙한 키까지 확실히 막아야 한다면 뮤텍스 락이 더 견고합니다. 값이 아예 없는 상황에서 재계산을 한 번으로 강제하는 명확한 방어선이기 때문입니다. 현실적으로는 둘을 함께 써서, 평상시에는 XFetch로 몰림을 흩고 최악의 경우에 뮤텍스가 받쳐주게 하는 조합을 권합니다.