메인으로 돌아가기

캐시 스탬피드(Cache Stampede) 현상과 3대 백엔드 플랫폼별 방어 구현

대규모 트래픽 환경에서 특정 캐시 키가 만료(Expire)되는 순간, 수많은 동시 요청이 한꺼번에 원본 데이터베이스(RDB 등)로 몰려 과부하를 일으키는 현상을 캐시 스탬피드(Cache Stampede) 혹은 뮤텍스 스톰(Mutex Storm)이라고 부릅니다.

1. 캐시 스탬피드란?

캐시는 데이터베이스의 부하를 덜어주기 위한 훌륭한 수단이지만, 캐시의 수명이 다하는 찰나의 순간에 동시다발적인 Read 요청이 들어올 경우 백엔드 서버들은 캐시 미스(Cache Miss)를 인지하고 동시에 DB 쿼리를 전송하게 됩니다. 이로 인해 커넥션 풀 고갈 및 DB CPU 100% 현상이 나타나 서비스 전체가 다운될 수 있습니다.

2. 단일 서버 vs 분산 다중 서버에서의 차이

캐시 스탬피드를 방어할 때는 백엔드 아키텍처가 단일 인스턴스(Single Instance)인지, 아니면 여러 대의 서버가 로드 밸런서 뒤에 배치된 분산(Distributed) 환경인지에 따라 솔루션의 적용 범위가 달라집니다.

  • 단일 서버 환경 (Local Mutex / Singleflight): 모든 트래픽이 하나의 서버 프로세스 안에서 처리되므로, 메모리 상의 Mutex 잠금이나 Go의 singleflight, Java의 ConcurrentHashMap 기반의 로컬 락만으로도 완벽한 스탬피드 제어가 가능합니다. 구현이 매우 가볍고 네트워크 레이턴시가 발생하지 않습니다.
  • 다중 서버/분산 환경 (Distributed Mutex & XFetch): 로컬 메모리 락은 단일 인스턴스 내의 스레드만 병합합니다. 만약 10대의 서버 노드가 실행 중이라면 캐시 만료 시 최대 10개의 중복 DB 조회가 발생하게 됩니다. 이를 차단하기 위해 Redis 분산 락(Redisson 등)을 획득한 하나의 노드만 DB를 조회하여 캐시를 업데이트하도록 통제하거나, 확률적 조기 만료(XFetch)를 적용해 특정 노드가 미리 백그라운드에서 캐시를 리프레시하도록 만들어야 합니다.

3. 대표적인 방어 전략

  • Singleflight / Mutex (이중 조회 방지): 동일한 키에 대한 캐시 갱신 작업을 단 하나의 스레드 또는 고루틴만 수행하고, 나머지 요청은 첫 번째 요청이 끝날 때까지 대기하거나 완료된 결과를 공유받도록 강제합니다.
  • 확률적 조기 만료 (XFetch 알고리즘): 캐시가 실제 만료되기 전, 조기 만료 여부를 확률적으로 계산하여 트래픽이 몰리기 전에 백엔드 중 하나가 비동기적으로 캐시를 미리 갱신해 둡니다.

4. Go, NestJS, Spring Boot 구현 예제

Go (Golang) - Singleflight

Go 표준 라이브러리인 golang.org/x/sync/singleflight는 캐시 스탬피드 방어에 최적인 도구입니다.

package main

import (
	"context"
	"fmt"
	"time"
	"golang.org/x/sync/singleflight"
)

type CacheService struct {
	group singleflight.Group
}

func (s *CacheService) GetData(ctx context.Context, key string) (string, error) {
	// 1. 캐시 조회 시도 (의사 코드)
	if val, found := getFromCache(key); found {
		return val, nil
	}

	// 2. singleflight를 통해 동일 키에 대한 DB 조회를 1회로 한정
	v, err, shared := s.group.Do(key, func() (interface{}, error) {
		// 실제 DB 또는 무거운 작업 실행
		time.Sleep(100 * time.Millisecond) // DB 부하 모사
		data := "Database_Result_For_" + key
		
		// 캐시 쓰기 진행
		setInCache(key, data)
		return data, nil
	})

	if err != nil {
		return "", err
	}
	
	fmt.Printf("Data: %s, Shared: %t\n", v.(string), shared)
	return v.(string), nil
}

Node.js (NestJS) - Promise Cache Pool

NestJS에서는 캐시 미스 발생 시 동일한 Key의 데이터베이스 비동기 작업을 맵에 Promise 형태로 보관하여 후속 요청들이 동일한 Promise를 await 하도록 설계할 수 있습니다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class CacheService {
  private promisePool = new Map>();

  async getData(key: string): Promise {
    // 1. 캐시 확인
    const cached = await this.getFromCache(key);
    if (cached) return cached;

    // 2. 이미 동일한 키로 DB 조회 작업이 돌고 있다면 그 Promise를 같이 구독
    let dbPromise = this.promisePool.get(key);
    if (!dbPromise) {
      dbPromise = (async () => {
        try {
          const dbResult = await this.queryDatabase(key);
          await this.setInCache(key, dbResult);
          return dbResult;
        } finally {
          this.promisePool.delete(key); // 작업 종료 후 풀에서 제거
        }
      })();
      this.promisePool.set(key, dbPromise);
    }

    return dbPromise;
  }

  private async getFromCache(key: string): Promise { return null; }
  private async setInCache(key: string, val: string): Promise {}
  private async queryDatabase(key: string): Promise { return 'DB_Result'; }
}

Kotlin (Spring Boot) - Double-Checked Locking

Spring Boot JVM 환경에서는 ConcurrentHashMapReentrantLock 또는 synchronized를 결합한 Double-Checked Locking 구조를 활용하여 동기화 처리를 진행합니다.

@Service
class CacheService {
    private val locks = ConcurrentHashMap()

    fun getData(key: String): String {
        // 1. 1차 캐시 확인 (Fast Path)
        getFromCache(key)?.let { return it }

        // 2. 키별 전용 락 획득
        val lock = locks.computeIfAbsent(key) { ReentrantLock() }
        lock.lock()
        try {
            // 3. 2차 캐시 확인 (Double-Checked)
            getFromCache(key)?.let { return it }

            // 4. 무거운 DB 조회 및 캐시 갱신
            val dbResult = queryDatabase(key)
            setInCache(key, dbResult)
            return dbResult
        } finally {
            lock.unlock()
        }
    }

    private fun getFromCache(key: String): String? = null
    private fun setInCache(key: String, value: String) {}
    private fun queryDatabase(key: String): String = "DB_Result"
}

Mitigating Cache Stampede: XFetch & Singleflight in Go, NestJS, and Spring Boot

When a high-traffic cache key expires, a massive wave of concurrent requests can bypass the cache and hit the origin database simultaneously. This phenomenon is known as Cache Stampede or Thundering Herd.

1. What is Cache Stampede?

While caching is a great way to relieve database pressure, the precise moment a hot cache key expires can trigger an outage. Concurrent incoming requests all discover a cache miss and send queries to the database. This exhausts connection pools and maxes out database CPU, leading to total service failure.

2. Single-Server vs. Multi-Server (Distributed) Environments

When preventing cache stampedes, the solution scope changes significantly based on whether you run a single backend instance or a distributed multi-node topology behind a load balancer.

  • Single-Server Environment (Local Mutex / Singleflight): Because all concurrency happens within a single system process, local in-memory constructs like Mutex, Go's singleflight, or Java's ConcurrentHashMap are enough to fully block thundering herd loops. These are extremely lightweight and introduce zero network overhead.
  • Multi-Server / Distributed Environment (Distributed Locks & XFetch): In-memory locks only deduplicate concurrent threads *per server instance*. If you run 10 server nodes, they can still issue 10 duplicate queries to your DB upon cache expiration. To prevent this, you must introduce a Redis-based Distributed Lock to let only one designated node perform the query, or implement Probabilistic Early Expiration (XFetch) to let a node asynchronously refresh the cache in the background before the hard TTL expires.

3. Mitigation Strategies

  • Singleflight / Mutex (Deduplication): Ensures only one thread or goroutine performs the heavy resource-fetching query, forcing duplicate concurrent requests to wait and share the first transaction's outcome.
  • Probabilistic Early Expiration (XFetch): Uses probability math to expire and asynchronously recompute the cache before it actually hits the hard TTL boundary.

4. Go, NestJS, and Spring Boot Implementations

Go (Golang) - Singleflight

Go's official helper package golang.org/x/sync/singleflight deduplicates execution paths out-of-the-box.

// Refer to the KO tab code for Go implementation details.
// s.group.Do(key, func() (interface{}, error) { ... }) prevents duplicate DB calls.

Node.js (NestJS) - Promise Cache Pool

NestJS can hold database query Promises in an active Map, forcing concurrent requests to await the identical pending query.

// Refer to the KO tab code for NestJS implementation details.
// promisePool.set(key, dbPromise) shares a single promise among requests.

Kotlin (Spring Boot) - Double-Checked Locking

Spring Boot concurrent JVM requests can be synchronized per-key using a map of locks and double-checked retrieval blocks.

// Refer to the KO tab code for Kotlin/Spring Boot implementation details.
// ConcurrentHashMap of ReentrantLocks limits the query thread to exactly 1.