Thundering Herd와 Cache Stampede 해결하기

2025년 12월 05일

experience

시리즈캐시 패턴#5
# Cache# Redis# Thundering Herd# Cache Stampede# 분산락

들어가며

인기 상품의 캐시가 만료되는 순간, 무슨 일이 벌어질까요?

이번 편에서는 캐시 시스템에서 가장 까다로운 문제 중 하나인 Thundering HerdCache Stampede를 다룹니다.

ℹ️

이번 글의 범위

  • 다루는 것: 문제 정의, 분산락 해결책, 확률적 조기 만료, 캐시 워밍
  • 다루지 않는 것: 분산 캐시 클러스터 구성

문제 정의

Thundering Herd (천둥 치는 무리)

캐시가 만료되는 순간, 대량의 요청이 동시에 DB로 몰리는 현상입니다.

T0
캐시 만료 직전
초당 1,000명이 조회하는 인기 상품. 모든 요청이 캐시 히트로 빠른 응답
T1
캐시 만료 순간
1,000개 요청이 동시에 캐시 미스
T2
DB 폭주
1,000개 요청이 모두 DB 조회 시작 → DB 과부하!
📦
요청 1,000개
동시 요청
📦
캐시 (비어있음)
모두 미스
📦
DB
동시에 1,000개 쿼리!

Cache Stampede (캐시 폭주)

Thundering Herd의 결과로 발생하는 연쇄 장애입니다:

1
캐시 만료
TTL 만료로 캐시가 비워짐
2
대량 DB 요청
모든 요청이 DB로 직행
3
DB 응답 지연
과부하로 쿼리 시간 증가
4
요청 타임아웃
클라이언트가 타임아웃 후 재시도
5
추가 부하
재시도로 더 많은 요청 발생
6
DB 다운
결국 DB 서버 다운 → 전체 장애

Race Condition 문제

1편에서 언급했던 조회-갱신 race condition도 이때 더 심해집니다:

T1
요청 A
캐시 미스 → DB에서 가격 10,000원 조회 시작
T2
관리자
가격을 15,000원으로 수정
T3
관리자
캐시 삭제 (이미 비어있음)
T4
요청 A
DB 조회 완료 → 캐시에 10,000원 저장
T5
이후 요청들
캐시에서 10,000원 조회 (잘못된 값!)
🚨

인기 상품일수록 동시 요청이 많아서 이런 문제가 자주 발생합니다.


해결책 1: 분산락 (Mutex Lock)

개념

캐시 미스 시 한 요청만 DB를 조회하고, 나머지는 대기합니다.

📦
요청 1
캐시 미스 → 락 획득
📦
DB 조회
요청 1만 조회
📦
캐시 저장
결과 캐싱
📦
요청 2,3...
대기 후 캐시 히트!

구현

@Service
class LockBasedCacheService(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>,
    private val redissonClient: RedissonClient
) {
    private val log = LoggerFactory.getLogger(javaClass)

    companion object {
        private const val CACHE_KEY_PREFIX = "product:"
        private const val LOCK_KEY_PREFIX = "lock:cache-load:"
        private val CACHE_TTL = Duration.ofMinutes(30)
        private const val LOCK_WAIT_SECONDS = 3L
        private const val LOCK_LEASE_SECONDS = 5L
    }

    fun getProduct(id: Long): Product? {
        val cacheKey = "$CACHE_KEY_PREFIX$id"

        // 1. 캐시 확인
        val cached = redisTemplate.opsForValue().get(cacheKey)
        if (cached != null) {
            return cached as Product
        }

        // 2. 캐시 미스 → 분산락으로 DB 조회 직렬화
        return loadWithLock(id, cacheKey)
    }

    private fun loadWithLock(id: Long, cacheKey: String): Product? {
        val lockKey = "$LOCK_KEY_PREFIX$id"
        val lock = redissonClient.getLock(lockKey)

        try {
            // 락 획득 시도 (최대 3초 대기)
            val acquired = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)

            if (acquired) {
                try {
                    // Double-check: 다른 스레드가 이미 캐시를 채웠을 수 있음
                    val rechecked = redisTemplate.opsForValue().get(cacheKey)
                    if (rechecked != null) {
                        log.debug("Cache filled by another thread - key: {}", cacheKey)
                        return rechecked as Product
                    }

                    // DB 조회
                    log.debug("Loading from DB - id: {}", id)
                    val product = productRepository.findById(id).orElse(null)
                        ?: return null

                    // 캐시 저장
                    redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
                    return product

                } finally {
                    lock.unlock()
                }
            } else {
                // 락 획득 실패 → 잠깐 대기 후 캐시 재확인
                log.debug("Lock acquisition failed, waiting... - key: {}", cacheKey)
                Thread.sleep(100)

                val retryCache = redisTemplate.opsForValue().get(cacheKey)
                if (retryCache != null) {
                    return retryCache as Product
                }

                // 여전히 없으면 DB 직접 조회 (fallback)
                return productRepository.findById(id).orElse(null)
            }
        } catch (e: InterruptedException) {
            Thread.currentThread().interrupt()
            log.error("Lock interrupted - id: {}", id, e)
            return productRepository.findById(id).orElse(null)
        }
    }
}
분산락의 장단점
장점
  • 확실한 방지 - DB 부하를 확실히 줄임 (동시에 1개만 조회)
  • Race Condition 방지 - Stale 데이터 캐싱 문제 해결
단점
  • 오버헤드 - 락 획득/해제 비용 발생
  • 단일 장애점 - 락 서버(Redis) 장애 시 영향
  • 복잡도 - 구현 복잡도 증가

해결책 2: 확률적 조기 만료 (Probabilistic Early Expiration)

개념

TTL 만료 직전에 일부 요청이 미리 캐시를 갱신합니다.

29분
요청 1
캐시 히트 (그냥 반환)
29분
요청 2
캐시 히트 (그냥 반환)
29분
요청 3
캐시 히트 + "곧 만료되니 갱신하자!" → 백그라운드 DB 조회
29분
요청 4
캐시 히트 (그냥 반환)
30분
TTL 만료
이미 갱신됨! → Thundering Herd 없음

구현

@Service
class ProbabilisticCacheService(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>
) {
    private val log = LoggerFactory.getLogger(javaClass)

    companion object {
        private const val CACHE_KEY_PREFIX = "probabilistic:product:"
        private val CACHE_TTL = Duration.ofMinutes(30)
        private const val EARLY_EXPIRATION_THRESHOLD = 0.1  // TTL의 10% 남으면 갱신 고려
        private const val REFRESH_PROBABILITY = 0.1         // 10% 확률로 갱신
    }

    private val executor = Executors.newCachedThreadPool()

    fun getProduct(id: Long): Product? {
        val cacheKey = "$CACHE_KEY_PREFIX$id"

        val cached = redisTemplate.opsForValue().get(cacheKey)
        if (cached != null) {
            // 조기 갱신 체크
            checkEarlyRefresh(id, cacheKey)
            return cached as Product
        }

        // 캐시 미스 → 일반 로드
        return loadAndCache(id, cacheKey)
    }

    /**
     * 확률적 조기 만료 체크
     *
     * TTL이 얼마 안 남았고, 확률에 당첨되면 백그라운드로 갱신
     */
    private fun checkEarlyRefresh(id: Long, cacheKey: String) {
        val ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS)
        if (ttl == null || ttl < 0) return

        val totalTtlSeconds = CACHE_TTL.seconds
        val remainingRatio = ttl.toDouble() / totalTtlSeconds

        // TTL이 10% 미만 남았고, 10% 확률에 당첨되면 갱신
        if (remainingRatio < EARLY_EXPIRATION_THRESHOLD && Math.random() < REFRESH_PROBABILITY) {
            log.debug("Early refresh triggered - key: {}, remaining: {}s", cacheKey, ttl)

            // 백그라운드로 갱신 (현재 요청은 기다리지 않음)
            executor.submit {
                try {
                    loadAndCache(id, cacheKey)
                    log.debug("Early refresh completed - key: {}", cacheKey)
                } catch (e: Exception) {
                    log.error("Early refresh failed - key: {}", cacheKey, e)
                }
            }
        }
    }

    private fun loadAndCache(id: Long, cacheKey: String): Product? {
        val product = productRepository.findById(id).orElse(null) ?: return null
        redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
        return product
    }
}
확률적 조기 만료의 장단점
장점
  • 락 불필요 - 분산락 없이 구현 가능
  • 부하 분산 - 자연스럽게 갱신 부하가 분산됨
  • 최소 변경 - 기존 코드 변경 최소화
단점
  • 완벽하지 않음 - 운이 나쁘면 동시 갱신 발생 가능
  • 불필요한 갱신 - 조기 갱신이 불필요할 수도 있음

해결책 3: 캐시 워밍 (Cache Warming)

개념

서버 시작 시 또는 주기적으로 미리 캐시를 채워둡니다.

📦
서버 시작
📦
인기 상품 조회
DB에서 Top N 조회
📦
캐시에 저장
미리 채워둠
📦
서비스 시작
첫 요청부터 캐시 히트!

구현

@Component
class CacheWarmingRunner(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>
) : ApplicationRunner {

    private val log = LoggerFactory.getLogger(javaClass)

    companion object {
        private const val CACHE_KEY_PREFIX = "product:"
        private val CACHE_TTL = Duration.ofMinutes(30)
        private const val TOP_PRODUCTS_COUNT = 100
    }

    override fun run(args: ApplicationArguments) {
        log.info("Starting cache warming...")

        try {
            // 인기 상품 Top 100 조회
            val topProducts = productRepository.findTopByOrderByViewCountDesc(TOP_PRODUCTS_COUNT)

            topProducts.forEach { product ->
                val cacheKey = "$CACHE_KEY_PREFIX${product.id}"
                redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
            }

            log.info("Cache warming completed - {} products cached", topProducts.size)
        } catch (e: Exception) {
            log.error("Cache warming failed", e)
            // 실패해도 서버는 정상 시작 (캐시 미스로 동작)
        }
    }
}
캐시 워밍의 장단점
장점
  • 완전 방지 - 캐시가 항상 채워져 있어 Thundering Herd 없음
  • 단순한 구현 - 복잡한 로직 없이 구현 가능
  • 빠른 응답 - 첫 요청부터 캐시 히트
단점
  • 데이터 선정 필요 - 어떤 데이터를 워밍할지 결정해야 함
  • 불필요한 캐싱 - 안 쓰는 데이터까지 캐싱할 수 있음
  • 메모리 사용량 - 미리 캐싱하므로 메모리 사용 증가

해결책 비교

해결책복잡도Thundering HerdRace Condition추가 부하
분산락높음완전 방지완전 방지락 서버
확률적 조기 만료중간대부분 방지부분 방지약간의 조기 갱신
캐시 워밍낮음완전 방지 (워밍된 데이터)방지 안 됨주기적 배치

권장 조합

🌳
어떤 종류의 데이터인가요?

예제 프로젝트: 조합 구현

StampedePreventionService

분산락 + 확률적 조기 만료를 조합한 서비스입니다:

@Service
class StampedePreventionService(
    private val productRepository: ProductRepository,
    private val redisTemplate: RedisTemplate<String, Any>,
    private val redissonClient: RedissonClient
) {
    private val log = LoggerFactory.getLogger(javaClass)
    private val executor = Executors.newCachedThreadPool()

    companion object {
        private const val CACHE_KEY_PREFIX = "stampede:product:"
        private const val LOCK_KEY_PREFIX = "lock:stampede:"
        private val CACHE_TTL = Duration.ofMinutes(30)
    }

    /**
     * 분산락 + 확률적 조기 만료 조합
     */
    fun getProduct(id: Long): Product? {
        val cacheKey = "$CACHE_KEY_PREFIX$id"

        val cached = redisTemplate.opsForValue().get(cacheKey)
        if (cached != null) {
            // 확률적 조기 만료 체크
            checkEarlyRefresh(id, cacheKey)
            return cached as Product
        }

        // 캐시 미스 → 분산락으로 안전하게 로드
        return loadWithLock(id, cacheKey)
    }

    private fun checkEarlyRefresh(id: Long, cacheKey: String) {
        val ttl = redisTemplate.getExpire(cacheKey, TimeUnit.SECONDS) ?: return
        val remainingRatio = ttl.toDouble() / CACHE_TTL.seconds

        // TTL 10% 미만, 10% 확률
        if (remainingRatio < 0.1 && Math.random() < 0.1) {
            executor.submit {
                loadWithLock(id, cacheKey)
            }
        }
    }

    private fun loadWithLock(id: Long, cacheKey: String): Product? {
        val lock = redissonClient.getLock("$LOCK_KEY_PREFIX$id")

        return try {
            if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
                try {
                    // Double-check
                    redisTemplate.opsForValue().get(cacheKey)?.let {
                        return it as Product
                    }

                    val product = productRepository.findById(id).orElse(null)
                        ?: return null
                    redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL)
                    product
                } finally {
                    lock.unlock()
                }
            } else {
                // 락 실패 → 잠깐 대기 후 캐시 재확인
                Thread.sleep(100)
                redisTemplate.opsForValue().get(cacheKey) as? Product
                    ?: productRepository.findById(id).orElse(null)
            }
        } catch (e: InterruptedException) {
            Thread.currentThread().interrupt()
            productRepository.findById(id).orElse(null)
        }
    }
}

마치며

분산락
완전 방지
복잡도 높음
조기 만료
대부분 방지
구현 간단
캐시 워밍
인기 데이터
미리 채워둠
💡

실무에서는 상황에 맞게 조합해서 사용하세요.

  • 인기 상품: 캐시 워밍 + 분산락
  • 일반 상품: 확률적 조기 만료
  • 실시간 데이터: 캐시 사용 안 함

다음 편(마지막)에서는 지금까지 배운 내용을 종합해서, 실전 시나리오별 캐시 전략 가이드를 정리합니다.


시리즈 목차

  1. 캐시 패턴 개요 및 비교
  2. Spring Boot + Redis 설정 및 Cache-Aside 구현
  3. Write-Through / Write-Behind 패턴 구현
  4. Cache Invalidation 전략
  5. Thundering Herd / Cache Stampede 해결 (현재 글)
  6. 실전 시나리오별 캐시 전략 가이드
© 2025, 미나리와 함께 만들었음