성능 최적화 14편 - 캐시 스탬피드(Cache Stampede) 방지하기
3편에서 Redis로 조회 성능을 개선하는 캐싱 전략을 다뤘습니다. 그런데 캐싱을 적용했다고 해서 모든 순간이 안전한 것은 아닙니다. 특히 캐시가 만료되는 바로 그 순간, 오히려 캐싱 이전보다 더 심각한 부하가 발생할 수 있습니다. 이번 글에서는 이 현상, 캐시 스탬피드(Cache Stampede)와 그 해결 방법을 정리합니다.
1. 캐시 스탬피드란
인기 상품 정보처럼 트래픽이 몰리는 데이터를 TTL 30분으로 캐싱했다고 가정해보겠습니다.
@Cacheable(value = "products", key = "#id")
public ProductDto getProduct(Long id) {
return productRepository.findById(id)
.map(ProductDto::from)
.orElseThrow();
}
30분 동안은 Redis가 요청을 잘 받아내다가, TTL이 만료되는 순간 문제가 발생합니다. 캐시가 비어있는 그 짧은 순간에 동시에 수백~수천 개의 요청이 한꺼번에 몰리면, 이 요청들이 전부 캐시 미스(Cache Miss)로 판단되어 그대로 데이터베이스로 쏟아집니다.
[캐시 만료 직전]
요청 1000개 -> Redis 조회 -> 캐시 히트 -> 빠른 응답
[캐시 만료 직후, 동시에]
요청 1000개 -> Redis 조회 -> 캐시 미스(전부) -> DB로 1000개 쿼리 동시 실행
캐싱으로 평소 DB 부하를 줄여놨던 만큼, 이 순간의 충격은 오히려 캐싱을 안 했을 때보다 더 클 수 있습니다. 특히 조인이 많거나 무거운 조회일수록, DB 커넥션 풀(7편)이 순식간에 고갈되며 장애로 이어지기도 합니다.
2. 해결책 1 - 분산 락으로 캐시 갱신을 한 번만 실행
캐시가 없을 때, 모든 요청이 DB를 조회하는 대신 딱 하나의 요청만 DB를 조회해서 캐시를 채우고, 나머지는 그 결과를 기다리도록 만드는 방법입니다. 6편에서 다룬 분산 락을 여기서도 활용할 수 있습니다.
@Service
@RequiredArgsConstructor
public class ProductCacheService {
private final RedisTemplate<String, ProductDto> redisTemplate;
private final ProductRepository productRepository;
private final RedissonClient redissonClient;
public ProductDto getProduct(Long id) {
String cacheKey = "product:" + id;
ProductDto cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
RLock lock = redissonClient.getLock("lock:product:" + id);
try {
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
// 락을 못 잡은 요청은 잠시 대기 후 캐시에서 다시 조회
return waitAndGetFromCache(cacheKey);
}
// 락을 잡은 요청만 DB 조회 후 캐시 갱신
ProductDto product = ProductDto.from(
productRepository.findById(id).orElseThrow());
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30));
return product;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
락을 먼저 획득한 요청 하나만 DB를 조회하고, 나머지 요청들은 잠시 대기했다가 이미 채워진 캐시를 읽습니다. 1000개의 동시 요청이 있어도, DB에는 단 1번의 쿼리만 전달됩니다.
3. 해결책 2 - TTL을 미묘하게 분산시키기 (Jitter)
같은 시각에 캐싱된 여러 키가 정확히 같은 순간 만료되면, 그 순간에 스탬피드가 몰릴 수 있습니다. TTL에 약간의 랜덤 값을 더해서 만료 시점을 분산시키는 방법도 함께 씁니다.
public void cacheProduct(Long id, ProductDto product) {
int baseTtlSeconds = 1800; // 30분
int jitterSeconds = new Random().nextInt(300); // 0~5분 랜덤 추가
redisTemplate.opsForValue().set(
"product:" + id, product, Duration.ofSeconds(baseTtlSeconds + jitterSeconds));
}
여러 상품이 동시에 캐싱되었더라도, 만료 시점이 30~35분 사이로 흩어지기 때문에 한꺼번에 몰려서 만료되는 상황 자체를 줄일 수 있습니다.
4. 해결책 3 - 논리적 만료(Logical Expiration)
TTL로 Redis가 키를 실제로 삭제하게 두는 대신, 값 안에 만료 시각 정보를 함께 저장하고 애플리케이션이 직접 만료 여부를 판단하는 방식입니다.
public class CacheWrapper<T> {
private T data;
private LocalDateTime expireAt;
}
public ProductDto getProduct(Long id) {
CacheWrapper<ProductDto> wrapper = redisTemplate.opsForValue().get("product:" + id);
if (wrapper == null) {
return loadAndCache(id); // 캐시 자체가 없으면 새로 조회
}
if (wrapper.getExpireAt().isBefore(LocalDateTime.now())) {
// 논리적으로는 만료됐지만, 일단 이 값을 반환하면서
// 백그라운드에서 비동기로 갱신 (Stale-While-Revalidate)
refreshAsync(id);
}
return wrapper.getData();
}
이 방식은 캐시가 "논리적으로 만료"되어도 실제로는 Redis에서 삭제되지 않기 때문에, 그 순간 들어온 요청들은 약간 오래된 값이라도 즉시 응답받고, 갱신은 백그라운드에서 비동기로(9편에서 다룬 @Async나 별도 스레드로) 처리됩니다. 정합성을 살짝 양보하는 대신, DB로의 순간적인 부하 폭증 자체를 원천적으로 막는 방식입니다.
5. 세 가지 방법 비교
| 방법 | 장점 | 단점 |
|---|---|---|
| 분산 락 | 구현이 직관적, DB 쿼리 1회로 확실히 제한 | 락 획득 대기로 인한 응답 지연 발생 가능 |
| TTL Jitter | 구현이 매우 간단 | 스탬피드를 줄일 뿐 완전히 막지는 못함 |
| 논리적 만료 | 응답 지연 없이 즉시 응답, 부하 분산 효과 확실 | 일시적으로 오래된 데이터를 보여줄 수 있음 |
트래픽이 아주 많은 인기 데이터라면 분산 락과 논리적 만료를 함께 조합하고, 상대적으로 여유 있는 데이터라면 TTL Jitter만으로도 충분한 경우가 많습니다.
6. 정리
- 캐시 스탬피드는 TTL 만료 순간에 동시 요청이 전부 DB로 몰리는 현상으로, 캐싱 이전보다 더 큰 부하를 유발할 수 있다
- 분산 락으로 캐시 갱신을 한 요청만 수행하도록 제한하거나, TTL에 Jitter를 더해 만료 시점을 분산시키는 방법이 있다
- 논리적 만료 방식은 정합성을 약간 양보하는 대신, 응답 지연 없이 부하를 안정적으로 분산시킬 수 있다
캐싱은 도입하는 것 자체보다, 캐시가 사라지는 순간을 어떻게 다룰지 설계하는 것이 실제 운영 안정성을 좌우합니다. 3편에서 다룬 기본 캐싱 전략에 이 내용을 함께 적용하면 훨씬 견고한 캐시 계층을 만들 수 있습니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 16 - 초기 로딩 속도 개선하기 (1) | 2026.07.13 |
|---|---|
| 성능 최적화 STEP 15 - 불필요한 리렌더링 줄이기 (0) | 2026.07.13 |
| 성능 최적화 STEP 13 - JVM GC 튜닝 (0) | 2026.07.10 |
| 성능 최적화 STEP 12 - No Offset (0) | 2026.07.09 |
| 성능 최적화 STEP 11 - Kafka로 무거운 작업 비동기 분리하기 (0) | 2026.07.09 |