Cache Stampede란?
Cache Stampede(Thundering Herd)는 동일한 캐시 키가 만료되는 시점에 다수의 요청이 동시에 DB로 몰리는 현상이다.
정상적인 캐시 흐름에서는 Cache Miss가 발생하면 하나의 요청이 DB에서 데이터를 가져와 캐시에 저장하고, 이후 요청은 캐시에서 처리된다. 하지만 캐시가 만료되는 바로 그 순간에 여러 요청이 동시에 도착하면 상황이 달라진다.

모든 요청이 캐시 미스를 경험하고, 각각 독립적으로 DB에 동일한 쿼리를 실행한다. 요청이 수백, 수천 건이라면 DB에 순간적인 부하 폭증이 발생한다.
왜 발생하는가?

Write Through로 해결할 수 없는가?
"캐시를 항상 최신 상태로 유지하면 만료될 일이 없지 않은가?"라는 생각이 들 수 있다. Write Through 전략은 쓰기 시점에 캐시를 갱신하여 일관성을 보장하지만, TTL 기반 캐시 구조에서는 만료 시점에 동시 요청이 몰리면 여전히 stampede가 발생할 수 있다. 결국 stampede를 해결하려면 캐시가 만료되는 읽기 시점에 초점을 맞춘 별도의 전략이 필요하다.
다만, Sorted Set과 같은 자료구조를 활용해 자주 조회되는 상위 20% 데이터를 TTL 없이 지속적으로 갱신하는 Materialized Cache 전략을 사용한다면, 해당 집합에 대해서는 만료 자체가 발생하지 않으므로 stampede 문제를 크게 완화할 수 있다.
하지만 이 방법은 stampede를 해결한다기보다는, 애초에 문제가 발생하지 않는 구조로 설계를 바꾸는 접근이다.
결국 stampede는 “읽기 시점에 캐시가 비어 있는 상황”에서 발생하므로, 이를 방지하려면 읽기 경로를 제어하는 별도의 전략이 필요하다.
이 문제를 해결하기 위한 다섯 가지 전략을 살펴보자. 각각 다른 관점에서 stampede를 완화한다.
해결 방법 1: Jitter — TTL 분산
개념
가장 단순한 접근이다. 캐시의 TTL에 랜덤한 편차(jitter)를 추가하여, 여러 키가 동시에 만료되는 것을 방지한다.

동작 흐름

구현
@Component
@RequiredArgsConstructor
public class JitterCacheRepository {
private final StringRedisTemplate redis;
private static final int JITTER_BOUND_SECONDS = 3;
/**
* @param key 캐시 키 (예: "product:1")
* @param ttl 캐시 유지 시간 (jitter가 적용되기 전 기준값)
* @param loader 캐시 미스 시 원본 데이터를 조회하는 함수
* @param type 반환 타입 클래스 (역직렬화에 사용)
*/
public <T> T fetch(String key, Duration ttl, Supplier<T> loader, Class<T> type) {
String cached = redis.opsForValue().get(key);
if (cached != null) {
return deserialize(cached, type);
}
return reload(key, ttl, loader);
}
public void put(String key, Duration ttl, Object value) {
redis.opsForValue().set(key, serialize(value), withJitter(ttl));
}
private Duration withJitter(Duration ttl) {
int jitter = RandomGenerator.getDefault()
.nextInt(-JITTER_BOUND_SECONDS, JITTER_BOUND_SECONDS + 1);
return ttl.plusSeconds(jitter);
}
private <T> T reload(String key, Duration ttl, Supplier<T> loader) {
T result = loader.get();
put(key, ttl, result);
return result;
}
}
캐시에 값을 저장할 때마다 withJitter를 통해 TTL에 랜덤 편차를 적용한다. 같은 시점에 캐싱된 데이터라도 만료 시점이 달라진다.
이후 코드에서 사용하는 serialize/deserialize는 JSON 직렬화/역직렬화 유틸 메서드이다. Jackson ObjectMapper 등으로 구현할 수 있으며, 본문에서는 캐시 전략에 집중하기 위해 생략한다.
서비스에서의 활용
이후 소개하는 전략들도 모두 동일한 fetch 시그니처를 사용한다. 서비스 코드에서는 다음과 같이 호출한다.
@Service
@RequiredArgsConstructor
public class ProductCacheService {
private final JitterCacheRepository cacheRepository;
private final ProductService productService;
public ProductResponse findById(Long productId) {
return cacheRepository.fetch(
"product:" + productId, // 캐시 키
Duration.ofSeconds(30), // TTL (기준값)
() -> productService.findById(productId), // Cache Miss 시 DB 조회 함수
ProductResponse.class // 역직렬화 대상 타입
);
}
}
특징과 한계

Jitter는 여러 키가 동시에 만료되는 "원인 1"에 효과적이다. 하지만 하나의 Hot Key에 요청이 집중되는 상황에서는 TTL을 아무리 분산해도 만료되는 그 순간의 동시 요청을 막을 수 없다.
해결 방법 2: Probabilistic Early Recomputation — 만료 전 선제적 갱신
개념
캐시가 만료되기 전에 확률적으로 미리 갱신하는 전략이다. 만료 시점에 가까워질수록 갱신 확률이 높아지고, 누군가 먼저 갱신하면 나머지 요청은 캐시에서 처리된다.
핵심 아이디어는 간단하다: 만료되기 전에 미리 갱신하면, 만료 시점에 stampede가 발생하지 않는다.

갱신 확률 공식
갱신 여부를 결정하는 공식은 다음과 같다:
현재시간 - delta * beta * ln(random) >= expiry
- delta: 마지막 갱신에 걸린 시간 (computation time)
- beta: 갱신 적극성을 조절하는 상수 (기본값 1)
- random: 0~1 사이 랜덤 값
- expiry: 캐시 만료 시각

ln(random)은 항상 음수(0~1의 로그)이므로, 마이너스를 곱하면 양수가 된다. delta가 클수록(갱신 비용이 높을수록), 그리고 만료 시점에 가까울수록 갱신 확률이 높아진다. 갱신 비용이 높은 데이터일수록 더 일찍 갱신을 시도하는 것이다.
구현
캐시에 데이터와 함께 메타데이터(갱신 소요 시간, 만료 시각)를 저장한다.
CacheEntry 클래스
public class CacheEntry {
private String data; // 직렬화된 원본 데이터
private long computationMillis; // 마지막 갱신 소요 시간 (delta)
private long expiresAtMillis; // 만료 시각 (expiry)
public static CacheEntry of(Object data, long computationMillis, Duration ttl) {
CacheEntry entry = new CacheEntry();
entry.data = serialize(data);
entry.computationMillis = computationMillis;
entry.expiresAtMillis = Instant.now().plus(ttl).toEpochMilli();
return entry;
}
// 현재시간 - delta * beta * ln(random) >= expiry
public boolean shouldRecompute(double beta) {
long now = Instant.now().toEpochMilli();
double random = RandomGenerator.getDefault().nextDouble();
return now - computationMillis * beta * Math.log(random) >= expiresAtMillis;
}
public <T> T parseData(Class<T> type) {
return deserialize(data, type);
}
}
PER CacheRepository
@Component
@RequiredArgsConstructor
public class PERCacheRepository {
private final StringRedisTemplate redis;
public <T> T fetch(String key, Duration ttl, Supplier<T> loader, Class<T> type) {
String cached = redis.opsForValue().get(key);
if (cached == null) {
return reload(key, ttl, loader);
}
CacheEntry entry = deserialize(cached, CacheEntry.class);
if (entry == null || entry.shouldRecompute(1.0)) {
return reload(key, ttl, loader);
}
return entry.parseData(type);
}
private <T> T reload(String key, Duration ttl, Supplier<T> loader) {
long start = Instant.now().toEpochMilli();
T result = loader.get();
long elapsed = Instant.now().toEpochMilli() - start;
CacheEntry entry = CacheEntry.of(result, elapsed, ttl);
redis.opsForValue().set(key, serialize(entry), ttl);
return result;
}
}
reload 시 데이터 조회에 걸린 시간(elapsed)을 측정하여 CacheEntry에 함께 저장한다. 이후 조회 시 shouldRecompute로 갱신 여부를 확률적으로 판단한다.
특징과 한계

PER은 만료 자체를 선제적으로 방지한다는 점에서 Jitter보다 근본적인 해결이다. 하지만 여러 요청이 동시에 갱신을 결정하면 중복 DB 조회가 발생할 수 있다. 이를 해결하려면 갱신 요청 자체를 하나로 병합하는 접근이 필요하다.
해결 방법 3: Request Collapsing — 갱신 요청 병합
개념
Cache Miss가 발생했을 때, 하나의 요청만 DB를 조회하고 나머지 요청은 갱신이 완료될 때까지 대기하는 전략이다.
"하나의 요청만 갱신한다"는 것은, 여러 요청 중 누가 갱신할지를 결정하는 동기화 메커니즘이 필요하다는 의미다. 단일 인스턴스라면 synchronized나 ReentrantLock같은 JVM 내부 락으로 충분하다.
하지만 서비스가 여러 인스턴스로 구성된 분산 환경이라면, 인스턴스 간에도 하나의 요청만 갱신하도록 조율해야 한다. 이 경우 Redis 기반 분산 락이 필요하다. 여기서는 분산 환경을 가정하고 구현한다.

동작 흐름 상세

폴링 대기 시 요청별 타임아웃이 반드시 필요하다. 락을 획득한 요청이 갱신에 실패하고 락을 해제한 후, 다른 요청이 새로 락을 획득하는 과정이 반복되면, 폴링 중인 요청은 이를 감지하지 못하고 무한히 대기할 수 있다. 타임아웃은 이런 상황에서의 최소한의 종료 정책이다.
구현
분산 락
분산환경이라는 가정하에 Redis를 활용하여 분산 락을 사용한다.
@Component
@RequiredArgsConstructor
public class RedisLockProvider {
private final StringRedisTemplate redis;
public boolean tryLock(String key, Duration ttl) {
Boolean result = redis.opsForValue().setIfAbsent(genKey(key), "", ttl);
return result != null && result;
}
public void releaseLock(String key) {
redis.delete(genKey(key));
}
private String genKey(String key) {
return "lock:" + key;
}
}
Redis의 SET NX (setIfAbsent) 명령을 사용한다. 키가 존재하지 않을 때만 설정에 성공하므로, 동시에 여러 요청이 시도해도 하나만 성공한다. TTL을 설정하여 락을 획득한 프로세스가 비정상 종료되더라도 락이 자동으로 해제된다.
Request Collapsing Repository
@Component
@RequiredArgsConstructor
public class CollapsingCacheRepository {
private final StringRedisTemplate redis;
private final RedisLockProvider lockProvider;
private static final long POLL_INTERVAL_MS = 50;
private static final long POLL_TIMEOUT_MS = 2000;
public <T> T fetch(String key, Duration ttl, Supplier<T> loader, Class<T> type) {
String cached = redis.opsForValue().get(key);
if (cached != null) {
return deserialize(cached, type);
}
// 1. 락 획득 성공 → 직접 갱신
String lockKey = "collapsing:" + key;
if (lockProvider.tryLock(lockKey, Duration.ofSeconds(3))) {
try {
return reload(key, ttl, loader);
} finally {
lockProvider.releaseLock(lockKey);
}
}
// 2. 락 획득 실패 → 폴링으로 갱신 대기
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(POLL_TIMEOUT_MS);
while (System.nanoTime() < deadline) {
cached = redis.opsForValue().get(key);
if (cached != null) {
return deserialize(cached, type);
}
try {
TimeUnit.MILLISECONDS.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
break;
}
}
// 3. 타임아웃 → fallback으로 직접 갱신
// 여기서는 fallback으로 직접 조회하게 했지만, 적절한 fallback 정책 필요
return reload(key, ttl, loader);
}
private <T> T reload(String key, Duration ttl, Supplier<T> loader) {
T result = loader.get();
redis.opsForValue().set(key, serialize(result), ttl);
return result;
}
}
특징과 한계

Request Collapsing은 분산 환경에서 강력하지만, 같은 인스턴스 내의 요청끼리도 Redis를 통해 조율해야 한다. 단일 인스턴스 내에서는 더 가벼운 방법이 있다.
해결 방법 4: Single Flight — 인메모리 요청 병합
개념
Request Collapsing과 목적은 같지만, Redis 분산 락 대신 JVM 내부의 ConcurrentHashMap과 CompletableFuture를 사용한다. 같은 인스턴스 내의 동시 요청을 네트워크 비용 없이 병합한다.
단, 분산환경일 경우 서비스의 개수만큼 DB에 초기 요청이 가게 된다.

핵심은 ConcurrentHashMap.computeIfAbsent의 원자성이다. 같은 키에 대해 여러 스레드가 동시에 호출해도, 최초 한 번만 Future를 생성하고 나머지는 같은 Future를 공유한다.
구현
@Component
@RequiredArgsConstructor
public class SingleFlightCacheRepository {
private final StringRedisTemplate redis;
private final Map<String, CompletableFuture<?>> singleFlightMap = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
public <T> T fetch(String key, Duration ttl, Supplier<T> loader, Class<T> type) {
String cached = redis.opsForValue().get(key);
if (cached != null) {
return deserialize(cached, type);
}
CompletableFuture<T> newFuture = new CompletableFuture<>();
CompletableFuture<T> existing = (CompletableFuture<T>) singleFlightMap.putIfAbsent(key,
newFuture);
if (existing != null) {
// 다른 스레드가 실행 중 → 기다림
return existing.join();
}
// 내가 실행자 (락 없이 실행)
try {
T result = reload(key, ttl, loader);
newFuture.complete(result);
return result;
} catch (Throwable t) {
newFuture.completeExceptionally(t);
throw t;
} finally {
singleFlightMap.remove(key, newFuture);
}
}
private <T> T reload(String key, Duration ttl, Supplier<T> loader) {
T result = loader.get();
redis.opsForValue().set(key, serialize(result), ttl);
return result;
}
}
reload에서 Redis에 결과를 저장한 후 inFlight.remove(k)가 실행되는 순서가 중요하다. 캐시 저장 → inFlight 제거 순서가 보장되므로, inFlight에서 제거된 이후 들어온 요청은 Redis에서 캐시 히트를 얻는다.
만약 이 순서가 반대라면(제거 → 저장), 제거와 저장 사이의 시간 창에 들어온 요청이 캐시 미스를 경험하고 새로운 DB 조회를 트리거하게 된다.
Request Collapsing vs Single Flight

| 비교 항목 | Request Collapsing | Single Flight |
| 병합 범위 | 클러스터 전체 (멀티 인스턴스) | 단일 JVM 인스턴스 |
| 동기화 메커니즘 | Redis 분산 락 + 폴링 | ConcurrentHashMap + Future |
| 대기 방식 | 주기적 Redis 조회 (폴링) | Future.join() (블로킹 대기) |
| 네트워크 비용 | 있음 (Redis 왕복) | 없음 (인메모리) |
| 적합한 환경 | 분산 환경, 여러 인스턴스 | 단일 인스턴스, 낮은 지연 요구 |
두 전략은 배타적이지 않다. Single Flight로 인스턴스 내부를 보호하고, Request Collapsing으로 인스턴스 간을 보호하는 조합도 가능하다.
해결 방법 5: Rate Limit — 요청량 제한
개념
앞의 전략들이 "stampede가 발생하지 않도록" 하는 접근이라면, Rate Limit은 stampede가 발생하더라도 DB가 감당할 수 있는 수준으로 요청을 제한하는 방어적 접근이다.

동작 원리
Redis의 INCR 명령으로 카운터를 구현한다. 첫 요청 시 카운터가 생성되고 TTL이 설정된다. 이후 요청마다 카운터를 증가시키고, 제한을 초과하면 요청을 거부한다.

INCR과 EXPIRE가 원자적이지 않으므로, INCR 직후 프로세스가 비정상 종료되면 EXPIRE가 설정되지 않아 카운터가 영구히 남을 수 있다. 이를 방어하기 위해 일정 간격(limit/10)마다 TTL을 확인하고, 누락된 경우 재설정한다.
구현
Rate Limiter
@Component
@RequiredArgsConstructor
public class FixedWindowRateLimiter {
private final StringRedisTemplate redis;
public boolean isAllowed(String id, long limit, long windowSeconds) {
String key = "rate-limit:" + id;
Long count = redis.opsForValue().increment(key);
if (count == null) {
return false;
}
// 첫 요청: 윈도우 시작
if (count == 1) {
redis.expire(key, Duration.ofSeconds(windowSeconds));
}
if (count <= limit) {
return true;
}
// EXPIRE 누락 방어: 주기적으로 TTL 확인
if (count % (limit / 10) == 0 && redis.getExpire(key) == -1) {
redis.expire(key, Duration.ofSeconds(windowSeconds));
}
return false;
}
}
서비스에서의 활용
@Service
@RequiredArgsConstructor
public class ProductRateLimitService {
private final ProductService productService;
private final FixedWindowRateLimiter rateLimiter;
public ProductResponse findById(Long productId) {
if (!rateLimiter.isAllowed("product:read", 100, 1)) {
throw new RateLimitExceededException("요청 한도 초과");
}
return productService.findById(productId);
}
}
특징과 한계

Rate Limit은 다른 전략들의 안전망 역할이다. Jitter, PER, Request Collapsing이 stampede를 예방하더라도, 예측 불가능한 상황에서 DB를 보호하는 마지막 방어선으로 함께 사용할 수 있다.
정리

| 전략 | 접근 방식 | 동작 범위 | 복잡도 | 적합한 상황 |
|---|---|---|---|---|
| Jitter | TTL에 랜덤 편차 | 키 전체 | 낮음 | 다수의 키가 동시 만료될 때 |
| PER | 만료 전 확률적 갱신 | 키 단위 | 중간 | Hot Key, 갱신 비용이 높은 데이터 |
| Request Collapsing | 락 + 폴링 | 멀티 인스턴스 | 높음 | 갱신 요청 병합 |
| Single Flight | ConcurrentHashMap + Future | 단일 인스턴스 | 중간 | 인스턴스 내부 요청 병합 |
| Rate Limit | 요청 수 제한 | 전체 | 낮음 | DB 보호 안전망 |
Cache Stampede는 캐시 만료라는 피할 수 없는 이벤트에서 발생한다.
단일 전략으로 모든 상황을 커버하기보다는, 서비스 특성에 맞게 여러 전략을 조합하는 것이 현실적이다.
Jitter로 만료 시점을 분산하고, PER로 Hot Key의 만료를 선제적으로 방지하며, Request Collapsing이나 Single Flight로 갱신 요청을 병합하고, Rate Limit으로 최후의 안전망을 구성하는 식이다.
'데이터베이스 > Redis' 카테고리의 다른 글
| [Redis] Cache Penetration: 존재하지 않는 데이터에 대한 요청이 캐시를 뚫고 DB를 때린다면 (0) | 2026.02.18 |
|---|---|
| Redis란? (1) | 2025.01.02 |