캐시와 캐시 스탬피드 이해 및 방지 전략
📌 캐시 스탬피드(Cache Stampede)란 무엇인가?
캐시 스탬피드(Cache Stampede) 는 동시에 다수의 요청이 캐시에서 데이터를 찾지 못해(캐시 미스), 일시에 백엔드(데이터베이스나 외부 API 등)에 과도한 부하를 주는 현상입니다. 이로 인해 서버 리소스가 고갈되거나 응답 속도가 급격히 느려지고, 심각한 경우 전체 서비스 장애로 이어질 수 있습니다.
이 문서에서는 캐시 스탬피드가 발생하는 전형적인 방식 을 설명하고, 이를 예방하기 위한 3가지 대응 전략을 Redis 기반 Java 애플리케이션 환경을 기준으로 소개합니다.
🔎 이 문서를 통해 알 수 있는 내용
- 캐시의 기본 동작 원리와 주요 이점
- 캐시 스탬피드가 발생하는 원인
- 적용 가능한 3가지 방지 전략
1. 캐시 스탬피드는 어떻게 발생하는가?
💥 전형적인 발생 시나리오
- 인기 상품 정보를 제공하는 API에 분당 수천 건의 요청이 들어옴
- 해당 데이터를 캐시에 저장하며 TTL(Time To Live)은 5분으로 설정
- 5분이 지나면서 캐시가 만료되고, 모든 요청이 동시에 캐시 미스를 발생시킴
- 동일한 데이터에 대한 수천 건의 요청이 백엔드(DB 또는 외부 API)로 전달됨
- 백엔드 과부하로 인해 서비스 지연 또는 장애 발생
주요 원인 정리
- 고정된 TTL 설정: 모든 캐시가 같은 시점에 만료되어 요청이 한꺼번에 몰림
- 요청 제어 부재: 캐시 미스 발생 시 요청을 제어하거나 분산할 메커니즘이 없음
- 병렬 요청 처리 미흡: 동시에 들어온 요청들이 백엔드에 그대로 전달되어 부하 발생
2. 캐시 스탬피드 방지 전략
캐시 스탬피드를 막기 위해 실무에서 널리 사용되는 다음의 세 가지 전략을 소개합니다.
각 전략은 상황에 따라 단독으로 또는 조합하여 사용할 수 있습니다.
2.1. 락을 이용한 단일 갱신
💡 설명
스탬피드는 캐시 미스 시 다수의 요청이 동시에 백엔드로 향하는 현상에서 발생합니다. 이를 방지하려면, 하나의 요청만 백엔드에 접근하게 하고 나머지는 대기하거나 기존 캐시를 재확인하도록 제어해야 합니다. 이 역할을 락(lock)이 수행합니다.
🔧 언제 유용한가?
- 갱신 대상 데이터가 비교적 느리게 조회되는 경우 (예: DB Join이 많은 쿼리)
- 같은 데이터에 대한 요청이 짧은 시간에 반복적으로 들어오는 경우
- 캐시 갱신 비용이 상대적으로 높은 경우
⚠️ 구현 시 주의할 점
- 락 획득 실패 시 적절한 fallback 로직(예: 일정 시간 후 재시도)을 구현해야 함
- 락 유지 시간 설정을 잘못하면 데드락 또는 불필요한 대기가 발생할 수 있음
- 분산 환경에서는 Redisson, ZooKeeper 등 외부 시스템을 사용한 분산 락이 필요함
✅ 예제 (Redisson 기반)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
RLock lock = redissonClient.getLock("lock::goods::populer");
if (lock.tryLock(2, 10, TimeUnit.SECONDS)) {
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached == null) {
Object data = fetchFromDB();
redisTemplate.opsForValue().set(key, data, Duration.ofMinutes(5));
return data;
}
return cached;
} finally {
lock.unlock();
}
} else {
// 다른 요청이 갱신 중이므로 잠시 후 캐시 재확인
Thread.sleep(100);
return redisTemplate.opsForValue().get(key);
}
2.2. TTL 분산(Randomized TTL)
💡 설명
고정된 TTL은 모든 캐시 항목이 동일한 시점에 만료되는 문제를 유발합니다. 무작위 시간(jitter)을 TTL에 더해 만료 시점을 자연스럽게 분산시키면, 동일 시점에 백엔드에 몰리는 요청 수를 줄일 수 있습니다.
🔧 언제 유용한가?
- 다수의 유사한 키에 동일한 TTL이 적용되는 경우 (예: 다수의 상품, 사용자 등)
- 갱신 대상 데이터가 비교적 가벼운 쿼리일 경우에도 부담을 완화하고 싶을 때
⚠️ 구현 시 주의할 점
- TTL이 지나치게 짧아지면 캐시 적중률이 떨어질 수 있으므로 jitter 범위를 적절히 설정해야 함
- TTL이 너무 넓게 퍼지면 일부 데이터가 불필요하게 오래 살아남는 문제가 발생할 수 있음
✅ 예제
1
2
3
int baseTtl = 300; // 5분
int jitter = new Random().nextInt(60); // 0~60초
redisTemplate.opsForValue().set(key, data, baseTtl + jitter, TimeUnit.SECONDS);
2.3. PER (Probabilistic Early Recomputation)
💡 설명
PER은 캐시 만료 전에 일정 확률로 캐시를 미리 갱신하는 전략입니다. 시간 경과에 따라 갱신 확률이 점점 증가하도록 설계되며, 만료 직전 트래픽 집중을 효과적으로 분산할 수 있습니다. 스탬피드를 방지하면서도 적중률을 최대한 유지하는 접근입니다.
🔧 언제 유용한가?
- 트래픽이 일정하지만 스파이크가 발생하기 쉬운 서비스 (예: 이벤트 상품)
- TTL을 정확히 예측하기 어렵고, 캐시가 끊기지 않는 것이 중요한 데이터
- 락 사용이 어렵거나 불필요한 경우 (락 도입 비용이 부담될 때)
⚠️ 구현 시 주의할 점
beta파라미터 설정에 따라 캐시 갱신 시점이 크게 달라지므로 충분한 튜닝과 테스트가 필요함- 시간 계산(
age,ttl)은 되도록 정확한 기준 시간으로 처리해야 예측 가능한 결과가 나옴 - 캐시 키 단위로 적용 시 연산 부하가 생길 수 있으므로, 필요 키에 한해 적용 고려
✅ 예제
1
2
3
4
public boolean shouldRefresh(long ttl, long age, double beta) {
double probability = Math.exp(-beta * age / ttl);
return Math.random() < probability;
}
ttl: 캐시 TTLage: 캐시가 사용된 시간beta: 민감도 조절 값 (보통 1~2)
3. 정리 및 결론
캐시는 성능과 응답 속도 개선에 매우 유용한 도구지만, 설정이나 동작 방식에 따라 예기치 않은 문제를 일으킬 수 있습니다. 그중에서도 캐시 스탬피드는 많은 요청이 동시에 백엔드로 몰리며 시스템에 부담을 주는 대표적인 사례입니다.
이 문서에서는 캐시 스탬피드를 방지할 수 있는 세 가지 실용적인 전략을 소개했습니다.
- 락 기반 갱신은 중복된 백엔드 호출을 방지해 요청 집중을 완화할 수 있고,
- TTL 분산은 캐시 만료 시점을 다양화해 트래픽 쏠림을 줄여줍니다.
- PER(Probabilistic Early Recomputation) 은 일정 확률로 캐시를 미리 갱신해, 안정성을 더할 수 있는 방식입니다.
각 전략은 상황에 따라 장단점이 있으며, 시스템의 특성과 트래픽 패턴에 맞게 선택하거나 조합해 적용할 수 있습니다.
꼭 모든 경우에 복잡한 방식을 도입해야 하는 것은 아니지만, 캐시와 관련된 이슈가 반복된다면 이러한 전략들을 검토해보는 것도 좋은 접근이 될 수 있습니다.
캐시 시스템이 일정 수준 이상의 부하를 감당해야 하거나, 예측하기 어려운 요청 패턴이 발생한다면, 단순한 캐싱만으로는 부족할 수 있습니다.
이럴 때는 이번에 소개한 전략처럼 요청을 분산하고, 갱신을 제어하는 방식을 함께 고려해보면 안정성 확보에 도움이 될 수 있습니다.