캐시 스탬피드 대비 스터디

개발자 팀·2026년 1월 31일

self-study-series

목록 보기
12/16

캐시를 잘 쓰면 우리의 애플리케이션 속도를 기하급수적으로 끌어올릴 수 있고 사용자들로 하여금 긍정적이고 쾌적한 서비스 이용 경험을 선서할 수 있습니다.
하지만 캐시가 무조건 만능일까요? 이번엔 캐시 스탬피드가 왜 발생하고 왜 위험한지에 대해서 알아보도록 하겠습니다.


캐시란?

일전에 다른 스터디 자료에서 캐시와 캐시 Aside 패턴에 대해서 다뤄보았습니다만, 이번에도 한 번 캐시가 무엇인지에 대해 짚고 넘어가겠습니다.
캐시는 쉽게 말해 자주 쓰는 데이터를 더욱 빠르게 꺼내기 위해 메모리에 저장해두는 것을 말합니다.
일반적으로 DB 조회는 컴퓨터 구조상 느릴 수 밖에 없습니다. 그렇기에 Redis 같은 인메모리 DB를 사용하여 속도를 급격하게 끌어올릴 수 있지요.
가장 흔한 패턴은 Cache Aside 패턴입니다.

즉 캐시를 항상 옆에 두고 DB에서 조회한 것을 캐시에 넣어놓고 중복된 요청이 오면 DB로 조회를 하는 것이 아니라 캐시에서 조회를 하는 방식이죠.
이를 통해 불필요한 DB I/O를 줄여 리소스 효율성을 극대화 할 수 있습니다.

캐시 스탬피드는 왜 문제인가?

그럼 캐시 스템피드는 왜 문제가 발생하는걸까요?
일반적으로 정상적인 상황에서 캐시에 있는 데이터를 꺼내올 경우(Hit) Redis가 대부분 빠르게 답변을 하기 때문에 DB 부하로 이어질 일이 거의 없습니다.
하지만 스탬피드가 발생하는 순간, 즉 캐시 Miss 가 터지는 순간 DB로의 요청이 한 번에 폭발적으로 늘어날 수 가 있습니다.
다시 말해 TTL이 만료되는 순간 요청 10,000개가 동시에 캐시 Miss가 났다고 해보죠. 그럼 이 1만건의 요청이 모두 DB로 전달될 것이고 이는 DB의 순간적 과부하를 일으켜 지연이나 락, 타임아웃 등의 장애 상황이 발생할 수 있습니다.
결론적으로 캐시 성능만 믿고 별다른 대응책을 마련해 놓지 않는다면 캐시가 Miss되는 순간 DB로 모든 부하가 몰리는 케이스 때문입니다.

캐시 스탬피드가 발생하는 대표적 시나리오들

시나리오 A : 인기 키의 TTL 만료

메인화면 베너나 인기 상품, 환율, 검색 필터, 공지사항 등 다양한 환경에서 조회수가 높은 조회가 많이 일어나는 키의 TTL이 만료되는 순간입니다.

시나리오 B : Redis 장애나 네트워크 이슈

Redis가 일시적 장애로 죽거나 느려지면 앱이 캐시를 사용할 수 없게 되고 그로 인해 Miss로 처리되게 되면 트래픽이 DB로 우회되어 DB에 부하가 걸릴 수 있습니다.

시나리오 C : 캐시 Eviction

Redis 메모라 부족해지면 LRU 정책 등에 의해 키가 제거될 수 있습니다. 이럴 때 다시 이 키가 인기키가 된다면 스탬피드 현상이 발생하게 됩니다.

이런 상황에서 스탬피드 현상이 발생되게 되는데 이럴때 관측된느 증상은 아래와 같습니다.

  • Redis Hit Ratio 급락
  • DB QPS의 순간 스파이크(특정 쿼리에서 폭팔적으로 발생)
  • p95 / p99 Latency의 급상승
  • Thread pool 고갈 및 커넥션 풀 고갈
  • 5xx 증가, 타임아웃 에러 증가

실무에서 가장 많이 사용되는 스탬피드 방지전략들

일반적으로 실무에서는 하나의 전략으로 스탬피드를 방지하는 것이 아니라 여러 가지를 조합하여 사용합니다.

1. TTL 지터(Jitter)로 동시 만료 분산

모든 키가 TTL 60초로 동일하면 60초마다 한꺼번에 많은 키들이 만료될 수 있습니다.
그래서 TTL에 랜덤값을 섞어 만료 시점을 분산시키는 전략을 사용하게 됩니다.
예를 들어 TTL이 60초라면 여기에 일정 시간을 추가하거나 빼서 50~70초 중 랜덤한 수로 바꾸는 작업을 하는 것이죠.
장점은 구현이 매우 쉽고 효과가 큽니다. 하지만 한 키가 압도적인 핫키라면 지터만으로는 부족할 수 있씁니다.

2. 싱글플라이트(SingleFlight) / 요청 합치기(Request Coalescing)

만약 캐시가 비었을 때 딱 1개의 요청만 DB를 조회해서 캐시를 채우게 하고 나머지 요청들은 잠깐 기다렸다 채워진 캐시를 읽게 하는 전략입니다.
즉, '같은 일을 여러번 하지 말고 1번만 하자' 전략이죠.
전체 흐름도를 그려보면 다음과 같습니다.

동일한 세 개의 요청(GET key)가 App으로 들어왔을 때 key가 현재 캐시(Redis)에 있지 않다면(Miss) DB에 락을 걸고 SELECT를 해옵니다. 이후 캐시에 저장 후 락을 풉니다.
이후 응답을 하게 되죠.
그럼 동일한 Request 2개는 그 이후에 Cache를 통해 응답을 하게 됩니다.
이럴 경우 장점은 Hot Key에도 매우 효과적으로 대응할 수 있다는 것이지만 락 대기나 타임아웃, 데드락 등에 대한 방지 설계가 반드시 필요합니다.

3. Stale-While-Revalidate(이전 값 먼저 주고 갱신!)

TTL이 만료되었더라도 우선 이전의 값, 즉 캐시가 갱신되기 전의 값을 먼저 반환합니다.
그 이후 백그라운드에서 새 값을 갱신하게 됩니다.
즉, 사용자는 체감상 항상 빠른 응답을 받게 되는 것이고 DB는 부하를 방지할 수 있습니다.
하지만 '조금 오래된 데이터'의 허용이 가능한 경우에만 사용할 수 있습니다.
예를 들어 재고 관리 시스템이나 결제 시스템에서 이런 방식을 쓴다면 아주 큰일이 나겠죠.

4. Refresh-Ahead(미리 갱신!)

이번엔 Refresh-Ahead 방식입니다. 굉장히 많이 사용되는 스탬피드 대비 전략이죠.
TTL이 끝나기 직전, 예를 들어 3초 혹은 그 이상 등의 환경 설정에 따라 누군가가 요청했을 때나 또는 스케줄러로 캐시가 있음에도 미리 DB를 조회하여 캐시를 새로 업데이트 해놓는 것을 말합니다.
이 전략을 사용할 경우 만료 순간 자체를 줄여 스탬피드가 완화될 수 있습니다만 역시나 동일한 TTL 만료 시점이 발생할 수 있기에 락이나 지터 등의 추가적인 전략이 필요하게 됩니다.

5. Negative Caching(없는 값도 캐시)

만약에 사용자가 정말 없는 데이터를 조회한다고 해도 이는 지속적인 DB 부하를 야기시킬 것입니다. 이럴 경우에는 존재하지 않는 데이터더라도 없음(null)을 짧은 TTL로 캐시하는 방법을 말합니다.
이렇게 정말 없을 경우 null을 짧게라도 캐시를 해두면 동일한 요청이 왔을 때 굳이 없는 데이터를 찾기 위해 DB까지 Request가 전달될 필요도 없겠죠.
이는 공격이나 오타, 봇 트래픽, 크롤링 등 다양한 트래픽 유발 상황에 대비해 방어에 활용할 수 있습니다. 하지만 즉시 생기는 데이터라면 null 응답은 유효하지 않은 응답이 되겠죠.

6. Rate limit / Circuit Breaker / Bulkhead

위 전략들은 이제 정말 DB로 요청이 몰려올 때 대응할 수 있는 장애 대응 전략입니다.
캐시에 해당하는 데이터가 캐싱되어있지 않을 때 갑작스럽게 DB로 부하가 몰리게 되면 장애가 발생하는데 이는 장애격리 패턴들 중 하나이죠.
일전에 회복탄력성에 대해 정리한 글이 있으니 참고 부탁드립니다.

다시 간단하게만 정리하면 Rate limit는 한 번에 들어올 수 있는 요청 수를 제한하는 것이고, Circuit Breaker는 문제 발생 시 해당 API로의 요청을 차단하는 것이고, 마지막 Bulkhead는 동시 실행 건수나 Thread Pool을 관리하여 장애의 진행이 다른 서비스에 영향을 주지 않게 하는 것을 말합니다.

실전에서 쓰일 수 있는 예제 코드

이번엔 실제 업무에서 쓰일 수 있는 수준의 예제 코드를 작성해봅니다.
여기서 핵심 포인트는 4가지입니다.
1. Cache-Aside 패턴 기본 구현
2. TTL 지터 적용
3. Redis 분산락으로 싱글플라이트 구현
4. Stale-While-Revalidate(옵션) 구현

위 핵심 포인트들을 가지고 구현을 한 번 해보겠습니다.

의존성

우리가 샘플에서 사용할 의존성은 Spring Data Redis를 활용할겁니다.
Redisson도 사용 가능하지만 여기선 원리 이해를 위해 기본 Redis를 활용합니다.

캐시 키/TTL 정책 클래스

package com.example.cache;

import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

/**
 * 캐시 TTL 정책을 한 곳에서 관리합니다.
 * - TTL 지터(Jitter)를 적용해 만료 시점을 분산합니다.
 */
public class CacheTtlPolicy {

    /**
     * baseSeconds: 기본 TTL(초)
     * jitterSeconds: 랜덤으로 더하거나 뺄 범위(초)
     *
     * 예: base=60, jitter=10  => 50~70초 범위
     */
    public static Duration ttlWithJitter(int baseSeconds, int jitterSeconds) {
        int delta = ThreadLocalRandom.current().nextInt(-jitterSeconds, jitterSeconds + 1);
        int seconds = Math.max(1, baseSeconds + delta); // 최소 1초 보장
        return Duration.ofSeconds(seconds);
    }
}

Redis를 활용한 분산락(SET NX + EX)구현

package com.example.cache;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.time.Duration;
import java.util.UUID;

/**
 * Redis 분산락의 "가장 기본 형태" 구현입니다.
 *
 * 락을 잡는 이유:
 * - 캐시 MISS일 때, 모든 요청이 DB를 조회하지 않게 하려는 것(싱글플라이트).
 *
 * 주의:
 * - 락 TTL은 반드시 설정해야 합니다(데드락 방지).
 * - unlock은 "내가 잡은 락인지" 확인 후 해제해야 합니다.
 */
public class RedisLock {

    private final StringRedisTemplate redisTemplate;

    public RedisLock(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 락 획득 시도
     * @return lockToken(내가 락 주인임을 증명하는 값), 실패하면 null
     */
    public String tryLock(String lockKey, Duration ttl) {
        String token = UUID.randomUUID().toString();

        Boolean ok = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, token, ttl);

        return Boolean.TRUE.equals(ok) ? token : null;
    }

    /**
     * 락 해제
     * - 반드시 "내 토큰"인지 확인 후 삭제해야 합니다.
     * - 실전에서는 Lua script로 compare-and-delete를 원자적으로 처리하는 것이 안전합니다.
     */
    public void unlock(String lockKey, String token) {
        String current = redisTemplate.opsForValue().get(lockKey);
        if (token != null && token.equals(current)) {
            redisTemplate.delete(lockKey);
        }
    }
}

위 unlock의 경우 GET 후 DEL이라 완전 원자적이지는 않습니다.
원자적인 처리를 위해서 실무에서는 Lua로 compare-and-delete를 권장하고 있습니다.

// Lua script 예시(설명용):
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
 else
   return 0
 end

Cache Repository

package com.example.cache;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.time.Duration;

/**
 * 간단 캐시 접근 클래스
 * - 실제로는 JSON(ObjectMapper) 또는 RedisSerializer 등을 사용합니다.
 */
public class SimpleCache {

    private final StringRedisTemplate redisTemplate;

    public SimpleCache(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public String get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public void set(String key, String value, Duration ttl) {
        redisTemplate.opsForValue().set(key, value, ttl);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }
}

Repository

package com.example.repo;

/**
 * Repository 인터페이스
 */
public interface ProductRepository {
    String findProductJsonById(String productId);
}

핵심! Cache Aside + 싱글플라이트 + TTL 지터

package com.example.service;

import com.example.cache.CacheTtlPolicy;
import com.example.cache.RedisLock;
import com.example.cache.SimpleCache;
import com.example.repo.ProductRepository;

import java.time.Duration;

/**
 * 캐시 스탬피드 방지의 핵심 예제:
 * - Cache-Aside
 * - TTL Jitter
 * - SingleFlight(분산락)
 */
public class ProductQueryService {

    private final SimpleCache cache;
    private final RedisLock redisLock;
    private final ProductRepository productRepository;

    public ProductQueryService(SimpleCache cache,
                               RedisLock redisLock,
                               ProductRepository productRepository) {
        this.cache = cache;
        this.redisLock = redisLock;
        this.productRepository = productRepository;
    }

    public String getProductJson(String productId) {
        String cacheKey = "product:" + productId;
        String lockKey  = "lock:" + cacheKey;

        // 1) 캐시 먼저 조회
        String cached = cache.get(cacheKey);
        if (cached != null) {
            return cached; // HIT
        }

        // 2) MISS: 락을 잡아 "한 요청만" DB 조회하게 만들기
        Duration lockTtl = Duration.ofSeconds(3); // DB 조회가 끝날 정도의 짧은 TTL 권장
        String token = redisLock.tryLock(lockKey, lockTtl);

        if (token == null) {
            // 2-1) 락 실패: 다른 요청이 이미 DB 조회 중
            // -> 잠깐 기다렸다가 캐시를 다시 확인(스핀을 과도하게 하지 않도록 주의)
            sleepSilently(50); // 50ms 대기
            String retry = cache.get(cacheKey);
            if (retry != null) {
                return retry; // 다른 요청이 채워둔 캐시를 사용
            }

            // 그래도 없으면, 최후의 수단으로 DB를 읽을 수 있지만
            // DB를 보호하려면 여기서 더 기다리거나, fallback을 주는 것이 안전할 수 있습니다.
            return loadFromDbAndCache(productId, cacheKey);
        }

        try {
            // 3) 락 성공: 이 요청만 DB에서 읽어서 캐시를 채움
            //    (중요) 락을 잡은 뒤에도 혹시 누가 채웠을 수 있으니 한 번 더 캐시 확인(더블 체크)
            String secondCheck = cache.get(cacheKey);
            if (secondCheck != null) {
                return secondCheck;
            }

            return loadFromDbAndCache(productId, cacheKey);
        } finally {
            // 4) 락 해제
            redisLock.unlock(lockKey, token);
        }
    }

    private String loadFromDbAndCache(String productId, String cacheKey) {
        // DB 조회 (느리다고 가정)
        String json = productRepository.findProductJsonById(productId);

        // (선택) 네거티브 캐싱: 없는 값도 짧게 캐시
        if (json == null) {
            Duration negativeTtl = Duration.ofSeconds(10);
            cache.set(cacheKey, "__NULL__", negativeTtl);
            return null;
        }

        // TTL 지터 적용: 60초 기본 + 10초 지터 => 50~70초
        Duration ttl = CacheTtlPolicy.ttlWithJitter(60, 10);
        cache.set(cacheKey, json, ttl);

        return json;
    }

    private void sleepSilently(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }
}

Stale-While-Revalidate

조금 오래된 값을 그대로 사용할 수 있는 상황이라면 Stale-While-Revalidate 방식을 쓰는 것도 좋은 방법입니다.
캐시 값과 함께 소프트 만료시간을 따로 저장해두고, 소프트 만료시간이 지나면 일단 기존 값을 응답해주고 백그라운드에서 데이터를 갱신하는 플로우로 진행됩니다.


마치며.

이번에는 캐시 스탬피드 방식에 대하여 정리해보고 이를 대비할 수 있는 방법을 스터디해보았습니다.
가장 기본적인 해결 조합은 Refresh-Ahead 방식을 활용한 미리 갱신 방법과 TTL 지터 + 싱플라이트 방식으로 안정적인 서비스를 서빙하는 방식입니다.
그 외에도 상황에 맞게 다양한 전략을 조합하여 사용할 수 있습니다.

긴 글 읽어주셔서 감사합니다 🫡

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글