Skip to content
메모장
Go back

캐시 무효화와 Cache Stampede 문제

개요

캐시 시스템의 두 가지 어려운 문제(Three Hard Things in Computer Science):

  1. 캐시 무효화 (Cache Invalidation)
  2. 변수명 짓기 (Naming Things)
  3. Off-by-one 오류

이 글은 1번을 다룬다. 특히 **Cache Stampede(Thundering Herd)**라 불리는 성능 저하 문제를 집중 분석한다.


1. Cache Stampede란?

시나리오

시간 → 

T0:00  [Cache: user:123 = "John"]
       TTL = 60초
       
T0:59  TTL 1초 남음
       
T1:00  ❌ [Cache Expired]
       1개 요청이 조회:
         user:123 = NULL (cache miss)
         → DB 조회 (100 ms)
         
       [동시에 다른 요청들이 몰림]
       999개 요청 (같은 순간)
         → 모두 cache miss
         → 모두 DB 조회 (동시에)
         → DB에 1,000개 쿼리 몰림
         → 데이터베이스 CPU 100%, 레이턴시 증가

메커니즘

캐시 1 미스
│
└─ DB 조회 시작 (100 ms 소요)
   │
   └─ [100 ms 동안 다른 요청들 대기]
      └─ T1:00 ~ T1:00.1 사이에 999개 요청 도착
         └─ 캐시 미스 (아직 갱신 안 됨)
         └─ 모두 DB 조회 시작
            └─ DB가 1,000개 쿼리를 동시에 처리
               └─ 엄청난 부하 spike

용어: Thundering Herd (울리는 떼)

컴퓨터 과학에서 “Thundering Herd Problem”은 대량의 프로세스가 동시에 깨어나 리소스에 접근하는 현상을 뜻한다.

━━━━━━ 캐시 미스 시간대 ━━━━━━
║
║  리소스 대기 중인 모든 요청 (1,000개)
║  동시에 깨어남 → 모두 DB 접근
║
║  ┌────┬────┬────┬────┐
║  │req1│req2│req3│req4│ ...
└──┴────┴────┴────┴────┘
    DB부하 폭주!

2. Cache Stampede 수학 모델

부하 계산

가정:

정상 상황:

1,000 req/s × 1% miss rate = 10 DB 쿼리/s
DB 부하: 낮음 (충분히 처리 가능)

Cache Stampede 발생 시:

T1:00에 캐시 만료
1,000 req/s × [100 ms 갱신 시간] = 100 동시 요청
이 중 캐시 미스 (새로운 데이터 갱신 안 됨): 99개
DB 쿼리: 99 req (정상 대비 10배 증가)

혹은 더 심각한 경우:
TTL 만료 후 첫 갱신이 느리면 (250 ms):
1,000 req/s × 250 ms = 250개 동시 요청
모두 cache miss → 250 DB 쿼리 (25배!)

영향도 분석

DB 응답 시간 증가 곡선:

정상 부하:           10 req/s
└─ 응답 시간: 10 ms

Cache Stampede:      100+ req/s (동시)
└─ 응답 시간: 50 ms (뮤텍스 대기)
└─ 계단식 악화: 느려진 응답 + 계속 몰려오는 요청
   └─ 결국 응답 시간 수백 ms까지 증가

연쇄 효과 (Cascading Failure):

사용자 API
  └─ user:123 캐시 미스
     └─ DB에서 user 정보 조회 (지연)
        └─ 같은 요청 100개 대기
           └─ 다른 API도 user 정보 필요해서 대기
              └─ 전체 서비스 느려짐 (캐시 snowball)

3. 해결 방법 1: 잠금 (Locking)

Lock-based Refresh

요청 1: GET user:123
  └─ cache miss
  └─ SET lock:user:123 (TTL 10초)
  └─ 성공 → DB 조회 시작
  
[동시 요청 100개 도착]
  └─ GET user:123
  └─ cache miss
  └─ SET lock:user:123 (잠금 이미 있음, 실패)
  └─ 대기 (또는 기존 캐시 반환)
  
요청 1: DB 조회 완료
  └─ SET user:123 <new value>
  └─ DEL lock:user:123
  
[대기 중인 요청들]
  └─ GET user:123 (이제 캐시 히트)

구현

public class CachedDataService {
    
    private static final String LOCK_PREFIX = "lock:";
    
    public String getUserName(Long userId) {
        String cacheKey = "user:" + userId;
        String lockKey = LOCK_PREFIX + cacheKey;
        
        // 1. 캐시 조회
        String cached = redis.get(cacheKey);
        if (cached != null) {
            return cached;  // ✓ 캐시 히트
        }
        
        // 2. 캐시 미스: 잠금 시도
        boolean lockAcquired = redis.setNX(lockKey, "1", Duration.ofSeconds(10));
        
        if (lockAcquired) {
            // 3a. 잠금 획득: DB 조회 및 캐시 갱신
            try {
                String value = db.getUserName(userId);  // 느린 쿼리
                redis.set(cacheKey, value, Duration.ofMinutes(1));
                return value;
            } finally {
                redis.delete(lockKey);  // 잠금 해제
            }
        } else {
            // 3b. 잠금 실패: 다른 요청이 갱신 중
            // 옵션 1: 기존 캐시 반환 (있으면)
            cached = redis.get(cacheKey);
            if (cached != null) {
                return cached;
            }
            
            // 옵션 2: 잠금 해제 대기
            for (int i = 0; i < 100; i++) {  // 최대 10초 대기
                if (!redis.exists(lockKey)) {
                    return redis.get(cacheKey);
                }
                Thread.sleep(100);  // 100ms 대기
            }
            
            // 옵션 3: 타임아웃 후 DB 직접 조회
            return db.getUserName(userId);
        }
    }
}

장단점

장점:

단점:


4. 해결 방법 2: 확률적 조기 갱신 (Probabilistic Early Expiration)

아이디어

캐시가 아예 만료되기 전에, 확률적으로 조기에 갱신한다.

TTL: 60초

T0:50  요청이 들어옴
       TTL 남은 시간: 10초
       
       재계산 확률 = (1 - exp(-decay_param × remaining_time))
                   = 1 - exp(-0.1 × 10)
                   = ~63% 확률로 재계산
       
       ✓ 재계산 시작
       
T0:51  TTL 정확히 60초가 되기 전에 이미 갱신됨
       → 캐시 만료 순간에 다른 요청들은 cache hit!

수식

decay_function(remaining_TTL):
  P(recalculate) = 1 - exp(-decay_rate × remaining_TTL)
  
typical values:
  decay_rate = 0.1
  
남은 시간 = 60초:  P = 1 - exp(-6) ≈ 0.0025 (0.25%)
남은 시간 = 50초:  P = 1 - exp(-5) ≈ 0.0067 (0.67%)
남은 시간 = 30초:  P = 1 - exp(-3) ≈ 0.0498 (5%)
남은 시간 = 10초:  P = 1 - exp(-1) ≈ 0.6321 (63%) ← 높음
남은 시간 = 1초:   P = 1 - exp(-0.1) ≈ 0.0952 (9.5%)

직관: TTL이 10초 이하로 남으면 갱신할 확률이 크게 증가한다.

구현

public class ProbabilisticCacheService {
    
    private static final double DECAY_RATE = 0.1;
    
    public String getUserName(Long userId) {
        String cacheKey = "user:" + userId;
        String ttlKey = "ttl:" + cacheKey;
        
        // 1. 캐시 조회
        String cached = redis.get(cacheKey);
        if (cached == null) {
            return queryAndCache(userId, cacheKey);  // cache miss
        }
        
        // 2. 남은 TTL 확인
        Long ttl = redis.ttl(cacheKey);  // 초 단위
        if (ttl == -1) {
            return cached;  // TTL 없음 (만료 안 됨)
        }
        
        // 3. 확률적 갱신 판단
        double recalculateProbability = 1 - Math.exp(-DECAY_RATE * ttl);
        if (Math.random() < recalculateProbability) {
            // 백그라운드 갱신 시작 (비동기)
            executorService.submit(() -> {
                String newValue = db.getUserName(userId);
                redis.set(cacheKey, newValue, Duration.ofMinutes(1));
            });
        }
        
        // 4. 현재 캐시 반환
        return cached;
    }
    
    private String queryAndCache(Long userId, String cacheKey) {
        String value = db.getUserName(userId);
        redis.set(cacheKey, value, Duration.ofMinutes(1));
        return value;
    }
}

장단점

장점:

단점:


5. 해결 방법 3: Xfetch 패턴

개념

[Client]
  ├─ GET key → (cache hit)
  ├─ 반환
  └─ 백그라운드에서 갱신 신호 전송
  
[Server]
  ├─ 신호 수신 (TTL이 낮은 캐시들)
  └─ 백그라운드 작업: key 갱신

더 정확하게는, 클라이언트가 “이 캐시는 시간이 얼마 안 남았으니 미리 갱신해달라”고 서버에 알린다.

구현

public class XfetchCacheService {
    
    private final ExecutorService refreshExecutor = 
        Executors.newFixedThreadPool(10);  // 백그라운드 갱신 전용
    
    public String getUserName(Long userId) {
        String cacheKey = "user:" + userId;
        
        CachedValue cached = redis.getWithTTL(cacheKey);
        if (cached == null) {
            return queryAndCache(userId, cacheKey);  // cache miss
        }
        
        // TTL이 30% 이하 남으면 백그라운드 갱신 신호
        if (cached.getTTL() < cached.getMaxTTL() * 0.3) {
            // 다른 요청이 이미 갱신 중이라면 스킵
            if (!redis.exists("refresh:pending:" + cacheKey)) {
                redis.set("refresh:pending:" + cacheKey, "1", Duration.ofSeconds(10));
                
                // 백그라운드 갱신
                refreshExecutor.submit(() -> {
                    try {
                        String newValue = db.getUserName(userId);
                        redis.set(cacheKey, newValue, Duration.ofMinutes(1));
                    } finally {
                        redis.delete("refresh:pending:" + cacheKey);
                    }
                });
            }
        }
        
        return cached.getValue();  // 오래된 값이라도 반환
    }
    
    private String queryAndCache(Long userId, String cacheKey) {
        String value = db.getUserName(userId);
        redis.set(cacheKey, value, Duration.ofMinutes(1));
        return value;
    }
}

장단점

장점:

단점:


6. 방법 비교

방법응답 시간DB 부하 제어Staleness구현 복잡도
Locking증가 (대기)✓ 우수없음중간
확률적 조기 갱신일정✓ 우수허용낮음
Xfetch일정✓ 우수허용높음

선택 기준

Locking:

확률적 조기 갱신:

Xfetch:


7. TTL 최적화 공식

최적의 TTL을 계산하는 방법:

TTL_optimal = (갱신에 소요되는 시간 × safety_factor)
            + (예상 cache stampede 영향도 고려)

예시:
갱신 시간: 100 ms
DB 쿼리당 비용: 5ms
예상 동시 요청: 1,000
            
요청율 × 갱신시간 = 1,000 req/s × 0.1s = 100 동시 요청

스탬피드 영향 제한 <= 10 DB 쿼리:
필요 TTL >= 100 req / 10 = 10초

따라서 TTL >= 10초 설정

실제로는 60~300초 권장 (갱신 시간 충분히 확보)

8. 모니터링

지표

redis-cli SLOWLOG GET 10
→ 느린 쿼리 분석 (캐시 미스 spike 감지)

redis-cli INFO stats
→ evictions, keyspace_hits, keyspace_misses
→ 캐시 히트율 (99% 이상 권장)

db의 slow query log
→ 동시 쿼리 수 모니터링
→ 일정하지 않은 부하 증가 = 스탬피드 징조

알람 설정

캐시 히트율 < 95% → 경보
→ cache stampede 가능성

DB 동시 연결 수 급증 (평상시 대비 2배+) → 경보
→ cache miss spike

결론

Cache Stampede 해결 방법:

  1. 단순함이 중요: 확률적 조기 갱신부터 시작
  2. 모니터링 필수: 캐시 히트율, DB 부하 추적
  3. 방법 조합: 확률적 갱신 + 잠금 (fallback)
  4. TTL 설정: 갱신 시간 × 2 이상 추천

Cache invalidation은 정말 어려운 문제지만, 패턴을 이해하면 대부분의 시스템에서 효과적으로 대응할 수 있다.


Share this post:

Previous Post
HTTP/2 vs HTTP/3(QUIC) - TCP 레벨 HOL Blocking
Next Post
Kafka 무엇일까