Published on

Spring Boot 3 Redis 캐시 스탬피드 해결법

Authors

서버 트래픽이 순간적으로 치솟을 때 Redis 캐시가 있음에도 DB가 갑자기 과부하로 무너지는 경우가 있습니다. 대표 원인이 캐시 스탬피드(cache stampede) 입니다. 특정 키가 동시에 만료되거나(동시 만료), 캐시에 없을 때(동시 미스) 여러 요청이 한꺼번에 DB를 때리면서 병목이 폭발합니다.

Spring Boot 3(특히 WebFlux/가상 스레드/스케일아웃 환경)에서는 처리량이 높아질수록 이 현상이 더 선명하게 드러납니다. 이 글에서는 증상 재현 → 원인 → 해결 패턴(락, single-flight, TTL 지터, stale-while-revalidate) → 운영 체크리스트 순서로, Redis 기반 캐시 스탬피드를 실전적으로 잡는 방법을 다룹니다.

또한 장애 분석 관점에서는 타임아웃/데드라인 관리까지 함께 봐야 합니다. 비슷한 “연쇄 장애” 패턴은 Cloud Run 504 Timeout 원인·해결 9가지gRPC MSA 데드라인 전파 누락 진단·해결에서 다룬 것처럼, 상위 계층의 타임아웃이 하위 자원을 더 압박하는 형태로 확산됩니다.

캐시 스탬피드가 발생하는 전형적인 시나리오

1) 동시 만료(Expiration Stampede)

  • 인기 키의 TTL이 같은 시각에 만료
  • 그 순간 들어온 수백/수천 요청이 캐시 미스를 만들고 DB로 몰림
  • DB 커넥션 풀 고갈, 쿼리 지연, 타임아웃 증가

2) 동시 미스(Cache Miss Stampede)

  • 신규 배포/캐시 flush/Redis 장애 복구 직후
  • 캐시가 비어있어 모든 요청이 DB로 직행

3) 핫키(Hot Key) + 느린 로더

  • 특정 키 조회가 압도적으로 많고
  • 로딩(예: 복잡한 조인, 외부 API 호출)이 느릴 때

Spring Boot 3 + Redis 캐시 구성의 함정

Spring Cache(@Cacheable)는 “캐시가 비어있으면 메서드를 실행해 채운다” 수준의 추상화입니다. 문제는 여러 스레드/여러 인스턴스가 동시에 같은 키에 대해 @Cacheable 메서드를 호출하면, 캐시가 비어있는 순간 각자 DB를 조회할 수 있다는 점입니다.

  • 단일 인스턴스 + 낮은 동시성에서는 잘 티가 안 납니다.
  • 오토스케일링/다중 인스턴스/고동시성에서 폭발합니다.

따라서 스탬피드 방지는 보통 아래 4가지를 조합합니다.

  1. TTL 지터(jitter) 로 동시 만료 자체를 줄이기
  2. single-flight(동일 키 동시 요청을 1개로 합치기)
  3. 분산 락(leader만 로딩)
  4. stale-while-revalidate(만료 직후에도 잠깐은 오래된 값 제공)

해결 1: TTL 지터로 “동시 만료” 완화

가장 저비용이며 효과가 큰 방법이 TTL에 랜덤 지터를 섞는 것입니다. 예를 들어 TTL을 10분으로 고정하지 말고 9분~11분 사이로 분산시키면, 특정 시각에 만료가 몰리는 현상이 크게 줄어듭니다.

Spring Cache의 기본 RedisCacheManager는 TTL을 고정으로 주기 쉽습니다. 키별 TTL을 세밀하게 제어하거나 지터를 넣으려면 아래 중 하나를 고려합니다.

  • 캐시를 직접 쓰는 레이어(예: StringRedisTemplate)에서 set 시 TTL을 랜덤화
  • 캐시 매니저를 커스터마이징하여 put 시점 TTL을 조정

아래는 StringRedisTemplate로 지터 TTL을 적용하는 예시입니다.

@Service
public class ProductCacheRepository {

    private final StringRedisTemplate redis;
    private final Duration baseTtl = Duration.ofMinutes(10);

    public ProductCacheRepository(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void put(String key, String jsonValue) {
        // baseTtl ± 60초 지터
        long jitterSeconds = ThreadLocalRandom.current().nextLong(-60, 61);
        Duration ttl = baseTtl.plusSeconds(jitterSeconds);

        redis.opsForValue().set(key, jsonValue, ttl);
    }

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

지터는 **스탬피드를 “완화”**하지만, 캐시 미스가 동시에 발생하는 상황(캐시 flush, 장애 복구, 신규 핫키 등장)에서는 여전히 부족합니다.

해결 2: single-flight로 동일 키 동시 로딩 합치기(인스턴스 내부)

먼저 “한 인스턴스 안에서” 동일 키에 대한 동시 로딩을 합치는 패턴입니다. 흔히 ConcurrentHashMapCompletableFuture를 넣어 같은 키에 대한 로딩을 1개만 수행하게 만들 수 있습니다.

  • 장점: 구현이 쉽고 Redis 락보다 가볍습니다.
  • 한계: 다중 인스턴스에서는 인스턴스별로 1개씩 로딩이 발생할 수 있습니다.
@Component
public class SingleFlight {

    private final ConcurrentHashMap<String, CompletableFuture<Object>> inFlight = new ConcurrentHashMap<>();

    @SuppressWarnings("unchecked")
    public <T> T doOnce(String key, Supplier<T> loader) {
        CompletableFuture<Object> future = inFlight.computeIfAbsent(key, k ->
            CompletableFuture.supplyAsync(() -> {
                try {
                    return loader.get();
                } finally {
                    inFlight.remove(k);
                }
            })
        );

        try {
            return (T) future.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

주의할 점:

  • supplyAsync의 실행 풀을 명시하지 않으면 공용 풀을 사용합니다. 트래픽이 큰 서비스라면 전용 executor를 두는 편이 안전합니다.
  • 로더가 느린 I/O(DB, HTTP)라면 타임아웃/서킷브레이커가 필요합니다.

single-flight는 “인스턴스 내부”에서만 효과가 있으므로, 스케일아웃 환경에서는 다음의 분산 락이 핵심입니다.

해결 3: Redis 분산 락으로 “오직 1명만” 캐시 재생성

핵심 아이디어는 간단합니다.

  1. 캐시 조회
  2. 미스면 락 키를 SET NX로 획득 시도
  3. 락을 획득한 1개 인스턴스만 DB 조회 후 캐시 채움
  4. 나머지는 짧게 대기 후 캐시 재조회(또는 stale 제공)

락 설계에서 중요한 포인트

  • 락은 반드시 만료 시간이 있어야 합니다(프로세스 죽으면 영원히 락).
  • 락 해제는 “내가 잡은 락인지” 확인 후 해제해야 합니다(토큰 비교).
  • 대기자는 무한정 기다리면 안 됩니다. 짧은 backoff 후 포기하거나 stale을 줘야 합니다.

아래는 StringRedisTemplate로 구현한 간단한 분산 락 예시입니다.

@Component
public class RedisLock {

    private final StringRedisTemplate redis;

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

    public String tryLock(String lockKey, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(lockKey, token, ttl);
        return Boolean.TRUE.equals(ok) ? token : null;
    }

    public boolean unlock(String lockKey, String token) {
        String lua = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
              return redis.call('del', KEYS[1])
            else
              return 0
            end
            """;

        Long res = redis.execute(
            new DefaultRedisScript<>(lua, Long.class),
            List.of(lockKey),
            token
        );
        return res != null && res > 0;
    }
}

그리고 캐시 로딩 로직은 아래처럼 구성합니다.

@Service
public class ProductService {

    private final StringRedisTemplate redis;
    private final RedisLock lock;
    private final ProductRepository repo;

    public ProductService(StringRedisTemplate redis, RedisLock lock, ProductRepository repo) {
        this.redis = redis;
        this.lock = lock;
        this.repo = repo;
    }

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

        String cached = redis.opsForValue().get(cacheKey);
        if (cached != null) return cached;

        String token = lock.tryLock(lockKey, Duration.ofSeconds(5));
        if (token == null) {
            // 락을 못 잡았으면 짧게 기다렸다가 캐시 재조회
            sleepSilently(50);
            String retry = redis.opsForValue().get(cacheKey);
            if (retry != null) return retry;

            // 여기서 정책 선택:
            // 1) 한 번 더 backoff
            // 2) DB 폴백(주의: 스탬피드 재발 가능)
            // 3) stale-while-revalidate 사용(권장)
            return loadFromDbAndCache(cacheKey, productId);
        }

        try {
            // 더블 체크: 락 잡는 사이 다른 인스턴스가 채웠을 수 있음
            String doubleCheck = redis.opsForValue().get(cacheKey);
            if (doubleCheck != null) return doubleCheck;

            return loadFromDbAndCache(cacheKey, productId);
        } finally {
            lock.unlock(lockKey, token);
        }
    }

    private String loadFromDbAndCache(String cacheKey, long productId) {
        String json = repo.findProductJson(productId); // DB 조회
        redis.opsForValue().set(cacheKey, json, Duration.ofMinutes(10));
        return json;
    }

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

이 방식은 스탬피드를 강하게 억제하지만, “락 대기 중 지연”이 생길 수 있습니다. 또 DB 조회가 느려 락 TTL을 초과하면, 다른 인스턴스가 락을 재획득해 중복 로딩이 발생할 수도 있습니다. 따라서 다음 패턴이 운영에서 더 부드럽습니다.

해결 4: stale-while-revalidate로 사용자 응답 지연 줄이기

stale-while-revalidate캐시가 만료되었더라도 일정 기간은 오래된 값을 즉시 반환하고, 백그라운드에서 재생성하는 전략입니다.

  • 장점: 사용자 지연이 급감하고, 트래픽 스파이크에 특히 강함
  • 단점: 잠깐 “오래된 데이터”를 줄 수 있음(허용 가능한 도메인인지 판단 필요)

구현 아이디어

Redis에 값을 저장할 때 두 개의 시간을 둡니다.

  • freshUntil: 이 시각 전까지는 무조건 신선
  • staleUntil: 이 시각 전까지는 stale 허용(즉시 반환)

값 구조를 JSON으로 저장해도 되고, 해시로 저장해도 됩니다. 여기서는 JSON 예시를 보겠습니다.

public record CacheEnvelope(String payload, long freshUntilEpochMs, long staleUntilEpochMs) {}

동작:

  • 현재 시간이 freshUntil 이전이면 그대로 반환
  • freshUntil은 지났지만 staleUntil 이전이면 payload 반환 + 락 잡고 비동기 갱신 시도
  • staleUntil도 지났으면(완전 만료) 락 기반 동기 로딩
@Service
public class StaleWhileRevalidateProductService {

    private final ObjectMapper om = new ObjectMapper();
    private final StringRedisTemplate redis;
    private final RedisLock lock;
    private final ProductRepository repo;
    private final Executor refreshExecutor;

    public StaleWhileRevalidateProductService(
        StringRedisTemplate redis,
        RedisLock lock,
        ProductRepository repo
    ) {
        this.redis = redis;
        this.lock = lock;
        this.repo = repo;
        this.refreshExecutor = Executors.newFixedThreadPool(8);
    }

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

        CacheEnvelope env = readEnvelope(cacheKey);
        long now = System.currentTimeMillis();

        if (env != null && now <= env.freshUntilEpochMs()) {
            return env.payload();
        }

        if (env != null && now <= env.staleUntilEpochMs()) {
            // stale 반환 + 백그라운드 갱신(락 잡히면 1개만 갱신)
            triggerRefreshAsync(cacheKey, lockKey, productId);
            return env.payload();
        }

        // 완전 만료: 락 기반으로 동기 로딩
        String token = lock.tryLock(lockKey, Duration.ofSeconds(5));
        if (token == null) {
            // 다른 인스턴스가 재생성 중일 확률이 큼: 짧게 대기 후 재조회
            sleepSilently(50);
            CacheEnvelope retry = readEnvelope(cacheKey);
            if (retry != null) return retry.payload();
            // 최후 폴백
            return repo.findProductJson(productId);
        }

        try {
            CacheEnvelope doubleCheck = readEnvelope(cacheKey);
            if (doubleCheck != null && System.currentTimeMillis() <= doubleCheck.staleUntilEpochMs()) {
                return doubleCheck.payload();
            }

            String json = repo.findProductJson(productId);
            writeEnvelope(cacheKey, json, Duration.ofMinutes(5), Duration.ofMinutes(30));
            return json;
        } finally {
            lock.unlock(lockKey, token);
        }
    }

    private void triggerRefreshAsync(String cacheKey, String lockKey, long productId) {
        CompletableFuture.runAsync(() -> {
            String token = lock.tryLock(lockKey, Duration.ofSeconds(5));
            if (token == null) return;
            try {
                String json = repo.findProductJson(productId);
                writeEnvelope(cacheKey, json, Duration.ofMinutes(5), Duration.ofMinutes(30));
            } finally {
                lock.unlock(lockKey, token);
            }
        }, refreshExecutor);
    }

    private CacheEnvelope readEnvelope(String key) {
        String raw = redis.opsForValue().get(key);
        if (raw == null) return null;
        try {
            return om.readValue(raw, CacheEnvelope.class);
        } catch (Exception e) {
            return null;
        }
    }

    private void writeEnvelope(String key, String payload, Duration freshTtl, Duration staleTtl) {
        long now = System.currentTimeMillis();
        CacheEnvelope env = new CacheEnvelope(
            payload,
            now + freshTtl.toMillis(),
            now + staleTtl.toMillis()
        );
        try {
            String raw = om.writeValueAsString(env);
            // Redis TTL은 staleTtl로 설정해 키가 너무 오래 남지 않게 함
            redis.opsForValue().set(key, raw, staleTtl);
        } catch (Exception ignored) {
        }
    }

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

이 패턴은 “사용자 응답”과 “백엔드 보호”를 동시에 만족시키기 좋습니다. 특히 상품 상세/콘텐츠/프로필처럼 몇 분~수십 분 stale이 치명적이지 않은 데이터에서 강력합니다.

Spring Cache(@Cacheable)를 계속 쓰고 싶다면

@Cacheable 자체로는 멀티 인스턴스 스탬피드를 막기 어렵습니다. 그래도 유지하고 싶다면 선택지는 다음과 같습니다.

  • @Cacheable(sync = true) 사용(단, 이는 보통 로컬 JVM 동기화 성격이라 분산 환경에서 한계가 있습니다)
  • @Cacheable은 “읽기”만 담당시키고, 갱신은 별도 컴포넌트에서 락 기반으로 수행
  • 아예 캐시 접근을 서비스 레이어로 끌어내려(위 예시처럼) 정책을 명확히 구현

운영에서 중요한 건 “추상화 유지”보다 스탬피드 시나리오에서 시스템이 어떻게 행동해야 하는지를 명시하는 것입니다.

운영 체크리스트: 스탬피드를 ‘장애’로 키우는 요소들

1) DB/외부 API 타임아웃을 짧게, 상위 데드라인을 전파

캐시 미스 시 DB로 몰리는 순간, 타임아웃이 길면 워커가 묶이고 큐잉이 발생합니다. 상위 요청 데드라인을 하위 호출에 전파하지 않으면 더 악화됩니다. 데드라인 전파 관점은 gRPC MSA 데드라인 전파 누락 진단·해결도 함께 참고하면 좋습니다.

2) 커넥션 풀/스레드 풀 관찰

  • DB 커넥션 풀 고갈
  • Redis 커넥션 풀 고갈
  • 서블릿 스레드(또는 이벤트 루프) 점유

스탬피드는 “캐시 문제”로 시작하지만, 결국 풀 고갈로 서비스 전체가 멈춥니다.

3) 락 TTL과 로딩 시간의 관계

  • 락 TTL은 “평균 로딩 시간”이 아니라 최악의 로딩 시간 P99를 기준으로 잡아야 합니다.
  • 로딩이 길어질 수 있는 경우(외부 API)에는 stale-while-revalidate가 더 안전합니다.

4) 핫키 분산

키 설계를 잘못하면 특정 키에 트래픽이 집중됩니다.

  • 사용자별 키로 쪼개기
  • 페이지네이션/필터 조합 키를 무작정 캐시하지 않기
  • 정말 핫한 키는 별도 프리워밍 또는 전용 TTL 정책

5) 장애 시나리오 리허설

  • Redis 재시작/플러시
  • 배포 직후 캐시 콜드 스타트
  • 특정 키 TTL 동시 만료

이런 리허설을 자동화해두면 회귀를 빨리 잡을 수 있습니다. 원인 추적 자동화 관점에서는 git bisect run으로 회귀 커밋 10분 추적 자동화 같은 접근도 운영 문화에 도움이 됩니다.

추천 조합(현업에서 무난한 정답)

  • 기본: TTL 지터 + 분산 락
  • 사용자 지연까지 줄이려면: TTL 지터 + stale-while-revalidate + 분산 락(백그라운드 갱신 보호)
  • 단일 인스턴스 또는 락 비용이 부담되면: single-flight + TTL 지터

정리하면, 캐시 스탬피드는 “캐시를 붙이면 해결”되는 문제가 아니라 캐시 미스가 동시에 발생했을 때의 행동을 설계해야 해결됩니다. Spring Boot 3에서 처리량이 커질수록 이 설계의 유무가 장애/무장애를 가릅니다.