Dev Log

Light

Cache Stampede 알아보기

Posted by , January 11, 2026
CacheRedisBackend

Cache Stampede는 캐시가 만료되는 순간, 다수의 요청이 DB로 몰리는 현상이다. 이는 캐시를 활용하고 있음에도 DB에 부하를 주며, 장애로 이어질 수 있다.

이 글에서는 구체적인 발생 원인과 해결 방법을 다룬다.

Cache Stampede 발생 원인

보통 변경이 빈번하지 않은 데이터는 조회 시 Cache Aside 패턴을 적용하기 때문에 DB를 직접 조회하는 빈도가 낮다. 하지만 캐시가 만료되는 순간, 해당 데이터를 조회하는 모든 요청이 순간적으로 DB에 집중될 수 있다.

이 현상은 다음 조건에 해당 될 때 발생한다.

  1. Hot Key : 특정 키에 요청이 집중된다.
  2. TTL 만료 : 해당 키의 캐시가 만료된다.
  3. 높은 트래픽 (동시 요청) : 만료 시점에 다수의 요청이 들어온다.

Cache Aside 패턴의 한계

Cache Aside 패턴의 핵심 로직은 다음과 같다.

  1. 캐시에서 데이터를 조회한다.
  2. 데이터가 캐시에 있으면 바로 반환한다.
  3. 만약 데이터가 없다면, DB에서 원본 데이터를 조회 후 캐시에 저장한 뒤 반환한다.

문제는 3번에서 발생한다. 캐시 미스가 발생하면 모든 요청은 독립적으로 각자 DB를 직접 조회한다. 요청 간 'DB를 조회하는 상태'를 공유하지 않기 때문이다.

초당 1000건의 요청이 발생한다고 가정하면, 캐시가 만료되는 순간 1000개의 동일한 DB 조회가 발생한다. 첫 번째 요청이 캐시에 데이터를 다시 저장하기 전까지 나머지 999개의 요청도 캐시 미스를 겪게 되고, 결국 DB를 조회하게 되는 것이다.

Cache Stampede 비교

즉, Cache Stampede 문제를 해결하기 위해서는 '동시에 여러 요청이 DB에 접근하는 것'을 막으면 된다.

이를 위한 몇 가지 해결 방법을 알아보자.

Cache Stampede 해결 방법

1. Lock (Mutex)

Cache miss가 발생하면 하나의 요청만 DB를 조회하고, 나머지는 대기한다.

Lock 메커니즘

첫 번째 요청이 DB에서 데이터를 가져와서 캐시에 데이터를 저장하면, 대기 중이던 요청은 캐시에서 데이터를 즉시 조회할 수 있다.

fun retrieveData(key: String): Data {
    // 1. 캐시에서 데이터를 조회한다. 존재하면 즉시 반환한다.
    cache.get(key)?.let { return it }

    // 2. 캐시에 데이터가 없으면 락 획득을 시도한다.
    val lock = redisLockRegistry.obtain(key)
    if (lock.tryLock(3, TimeUnit.SECONDS)) {
        try {
            // 3. 락을 획득한 사이 다른 요청이 캐시에 저장했을 수 있으므로 다시 확인한다.
            cache.get(key)?.let { return it }

            // 4. DB에서 데이터를 조회하고, 캐시에 저장한다.
            val data = db.findBy(key)
            cache.put(key, data, Duration.ofMinutes(5))

            return data
        } finally {
            lock.unlock()
        }
    }

    // 5. 락 획득에 실패하면 잠시 대기 후 재시도한다.
    Thread.sleep(100)
    return retrieveData(key)
}

단, 락을 획득하지 못한 요청은 대기해야 하므로 응답 지연이 발생 할 수 있다.

2. PER (Probabilistic Early Recomputation)

PER 메커니즘

캐시가 만료되기 전에 미리 갱신하는 방식이다. 확률적 갱신을 적용하며, TTL이 적게 남을수록 확률이 높아진다.

만료전에 캐시를 갱신한다는 점, 모든 요청이 갱신하는 것이 아닌 특정 확률로 동작한다는 점에서 Stampede 현상을 방지할 수 있다.

fun retrieveData(key: String): Data {
    // 1. 캐시에서 데이터와 메타정보를 조회한다.
    val cached = cache.getWithMetadata(key)
        ?: run {
            // 2. 캐시 미스 시 DB에서 조회 후 저장한다.
            val start = System.currentTimeMillis()
            val data = db.findBy(key)
            val queryTime = System.currentTimeMillis() - start

            cache.put(key, data, Duration.ofMinutes(5), queryTime)

            return data
        }

    // 3. 만료 전 갱신이 필요한지 확률적으로 계산한다.
    val ttlRemaining = cached.ttl.toSeconds()
    // `BETA`는 갱신 적극성을 조절하는 상수로, 일반적으로 1.0을 사용한다.
    val threshold = cached.queryTime * BETA * (-Random.nextDouble())

    // 4. TTL이 적게 남을수록 갱신 확률이 높아진다.
    if (ttlRemaining + threshold <= 0) {
        refreshAsync(key)
    }

    return cached.data
}

3. TTL Jitter

동일한 TTL을 여러 키에 부여하면, 동시에 다수의 키가 만료될 수 있다.

TTL Jitter는 만료 시간을 분산시켜서 Stampede 현상을 방지하는 방법이다. 캐시 만료 시간에 랜덤 값을 추가하는 방식으로 구현한다.

fun cache(key: String, data: Data, ttl: Duration) {
    // TTL에 +- 10% 범위에서 무작위 값을 추가한다.
    val jitterRange = ttl.toMillis() * 0.1
    val jitter = (Random.nextDouble() * 2 - 1) * jitterRange
    val actualTtl = ttl.toMillis() + jitter.toLong()

    cache.put(key, data, Duration.ofMillis(actualTtl))
}

References