[Redis] 캐시 스탬피드 현상

2025. 12. 14. 11:39·트러블슈팅

✅ 개요

Spring Boot 프로젝트에서 레디스를 사용하여 캐시를 사용하였을 때 발생한 캐시 스탬피드 현상에 대한 해결 과정을 기록하고자 합니다.

 

✅ 문제 상황

먼저 캐싱 대상은 AI 후기 요약 결과로 다음과 같습니다. 캐싱을 적용하여 기본적인 응답 시간 감소와 함께 LLM을 호출하는 횟수를 줄여 토큰 비용 감소를 기대했습니다.

@Cacheable(value = "postReviewSummary", key = "#postId")
public String summarizePostReviews(Long postId) {
    List<Review> reviews = reviewQueryRepository.findTop30ByPostId(postId);

    if (reviews.isEmpty()) {
        return "후기가 없습니다.";
    }

    String reviewsText = reviews.stream()
                                .map(Review::getComment)
                                .collect(Collectors.joining("\n"));

    return chatClient.prompt()
                     .system(reviewSummaryPrompt)
                     .user("후기:\n" + reviewsText)
                     .call()
                     .content();
}

 

이제 k6로 캐싱 전후의 캐시 적중률을 비교해 보겠습니다. 스크립트는 다음과 같이 10명의 사용자가 5번씩 동시에 요청을 보내는 간단한 시나리오를 구성했습니다.

import http from "k6/http";

export const options = {
  scenarios: {
    cachingTest: {
      executor: "per-vu-iterations",
      vus: 10,
      iterations: 5,
    },
  },
};

const POST_ID = 1;

export default function () {
  const url = `http://localhost:8080/api/v1/posts/${POST_ID}/reviews/summary/ai`;
  http.get(url);
}

 

캐싱 전

캐싱 전

  • 캐시 미스 : 50번
  • 캐시 히트 : 0번
  • 캐시 적중률 : 0%

캐싱 후

캐싱 후

  • 캐시 미스 : 10번
  • 캐시 히트 : 40번
  • 캐시 적중률 : 80%

캐싱 메트릭

`캐시 미스 횟수 = LLM 호출 횟수`이기 때문에 50번에서 10번으로 줄은 것은 기대한 대로 동작하였습니다. 하지만 캐시 미스가 10번 발생한 것에 의문을 가졌습니다. 이왕이면 첫 요청 한 번만 캐시 미스가 발생해 LLM을 호출해 캐싱하고, 이후 나머지 요청은 캐시값을 사용하는 구조를 만들 수 있으면 좋겠다고 생각했습니다.

💡캐싱 메트릭을 확인하기 위해서 `RedisCacheManager` 수동 빈 등록 대신(했다면) 다음과 같은 설정이 필요했습니다.
spring:
  cache:
    type: redis
    cache-names: postReviewSummary, {캐시 이름}, ...
    redis:
      enable-statistics: true​

✅ 원인 분석

저는 위와 같인 현상이 발생한 원인을 처음 동시에 요청하는 N개의 스레드에서 모두 캐시 미스가 발생했기 때문이라고 생각하고 이에 대해 알아보니 캐시 스탬피드 현상과 관련이 있음을 알게 되었습니다. 캐시 스탬피드 현상을 시퀀스 다이어그램으로 나타내면 다음과 같습니다.

 

캐시 스탬피드 시퀀스 다이어그램

✅ 해결 방법

위와 같은 캐시 스탬피드 현상을 해결할 수 있는 방법에는 지터, 분산락, PER 알고리즘 등 여러 가지가 있었는데 동시 요청으로 비싼 비용의 LLM 호출을 중복 실행하는 것을 방지하는 것이 중요하다고 생각하여 분산락을 적용해 보았습니다. 나머지 방법들은 밑에서 간략하게 정리해 보겠습니다.

`Redisson`을 사용해 분산락을 적용할 것이기 때문에 의존성을 추가합니다.

implementation("org.redisson:redisson-spring-boot-starter:3.41.0")

분산락을 적용한 서비스 로직은 다음과 같습니다.

public String summarizePostReviews(Long postId) {
    String lockKey = "lock:postReviewSummary:" + postId;
    RLock lock = redissonClient.getLock(lockKey);

    String cachedSummary = getCachedSummary(postId);
    if (cachedSummary != null) {
        log.info("캐시 히트 (락 전): postId={}", postId);
        return cachedSummary;
    }

    try {
        if (!lock.tryLock(10, 15, TimeUnit.SECONDS)) {
            log.error("락 획득 실패: postId={}", postId);
            throw new RuntimeException("락 획득 실패");
        }

        log.info("{} 락 획득!", lockKey);

        cachedSummary = getCachedSummary(postId);
        if (cachedSummary != null) {
            log.info("캐시 히트 (락 후): postId={} - 다른 스레드가 생성함", postId);
            return cachedSummary;
        }

        log.info("캐시 미스 - LLM 호출: postId={}", postId);
        List<Review> reviews = reviewQueryRepository.findTop30ByPostId(postId);

        if (reviews.isEmpty()) {
            cachedSummary = "후기가 없습니다.";
        } else {
            String reviewsText = reviews.stream()
                                        .map(Review::getComment)
                                        .collect(Collectors.joining("\n"));

            cachedSummary = chatClient.prompt()
                                      .system(reviewSummaryPrompt)
                                      .user("후기:\n" + reviewsText)
                                      .call()
                                      .content();
        }

        Objects.requireNonNull(cacheManager.getCache("postReviewSummary")).put(postId, cachedSummary);
        log.info("캐싱 완료: postId={}", postId);

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("락 획득 중 인터럽트", e);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info("{} 락 해제", lockKey);
        }
    }

    return cachedSummary;
}

private String getCachedSummary(Long postId) {
    Cache cache = cacheManager.getCache("postReviewSummary");
    if (cache != null) {
        return cache.get(postId, String.class);
    }
    return null;
}

락을 적용했을 때 주의할 점은 `log.info("캐시 히트 (락 후): ...")` 부분의 if문이었습니다. 현재 스레드가 락을 획득하는 시간에 다른 스레드가 이미 락을 획득하여 먼저 캐싱을 했을 가능성도 고려를 해야 합니다. 즉 락을 획득했다고 해서 무조건 LLM/DB를 불러오는 게 아닌 한번 더 동시 상황을 Double-Check 하는 것입니다.

분산락을 적용했을 때 시퀀스 다이어그램으로 표현하면 다음과 같습니다.

그리고 다시 k6로 테스트를 해보겠습니다.

캐싱 + 분산락
캐싱 메트릭

  • 캐시 미스 : 1번
  • 캐시 히트 : 49번
  • 캐시 적중률 : 98%

메트릭을 보면 총 50번 요청했지만 캐시 히트 49번, 캐시 미스 11번으로 총 60 요청 발생했다고 나옵니다. 이는 요청 중 일부가 위에서 언급한 현재 스레드가 락을 획득하는 시간에 다른 스레드가 이미 락을 획득하여 먼저 캐싱을 한 경우가 발생했기 때문입니다. 즉 Double-Check로 인해 일부 요청이 캐시를 두 번 조회하기 때문입니다. 또한 p(90), p(95)와 같이 중요한 지표는 오히려 시간이 늘어난 것을 확인할 수 있는데, 이는 락의 타임아웃 설정(`waitTime(10s)`, `leaseTime(15s)`)으로 인한 어쩔 수 없는 트레이드오프인 것 같습니다. k6 테스트의 극단적인 시나리오를 생각해 본다면 LLM을 호출하는 비용을 줄이는 것이 더 좋다고 생각했습니다.

 

어찌 됐든 결과적으로 분산락을 구현하여 캐시 적중률을 높임으로써 LLM 호출 비용을 줄이는 목표에 달성할 수 있게 되었습니다.

 

✅ 다른 방법들

캐시 스탬피드 현상을 해결할 수 있는 방법에는 여러 가지 방법이 있다고 언급했는데, 위에서 사용한 분산락 외에 대표적으로 다음과 같은 방법들이 있습니다.

1️⃣ 지터(Jitter)

지터는 전자공학/네트워크에서 사용되는 개념으로 "전자 신호를 읽는 과정에서 발생하는 짧은 지연 시간"을 의미합니다. 저는 이것을 캐시에서는 랜덤 TTL로 이해했고  캐시 만료 시간을 무작위로 조금 지연시키면, 캐시 스탬피드 상황에서도 부하를 균등하게 분산시킬 수 있다고 합니다. 예를 들어 캐시 만료 시간에 0~10초 사이의 무작위 지연 시간을 추가하면, 부하가 10초에 걸쳐 분산되는 것입니다.

 

2️⃣ 선계산 기법

캐시가 만료되기 전에 미리 데이터를 갱신하는 기법으로, 기존에는 여러 요청이 동시에 캐시 만료로 판단하고 DB 조회나 LLM 호출 등 트래픽 폭증이 발생하며 중복 읽기와 중복 쓰기가 발생하는 문제가 있었습니다. 이것을 방지하기 위해 선계산 기법에서는 `남은 TTL - 랜덤값 > 0`이면 캐싱값을 그대로 사용하고 그렇지 않으면 캐시를 갱신하는 방식으로 동작합니다. 즉 캐시가 완전히 만료되기 전에 일부 요청이 미리 캐시를 갱신하도록 유도하여 캐시 갱신을 분산하도록 합니다.

 

3️⃣ PER 알고리즘

PER(Probabilistic Early Recomputation) 알고리즘은 캐시가 만료되기 전 언제 캐시를 갱신하는 것이 최적일지 계산하는 메커니즘으로, 다음 공식을 기반으로 합니다.

currentTime - ( timeToCompute * beta * log(rand()) ) > expiry
  • `currentTime` : 현재 시각(currentTimeMillis)
  • `timeToCompute` : 캐시된 값을 다시 계산하는 데 걸리는 시간
  • `beta` : 갱신 확률을 조절하는 역할로, 값이 클수록 캐시 만료 시점보다 더 일찍, 더 자주 갱신을 시도(일반적으로 1.0 설정)
  • `rand()` : 0~1 사이의 랜덤값을 반환하는 함수
  • `expirt` : 캐시 만료 시각

이 공식은 캐시가 실제로 만료되기 전에 확률적으로 미리 재계산을 수행할지 결정합니다. 계산 비용(timeToCompute)이 클수록, 그리고 만료 시각에 가까울수록 재계산 확률이 높아집니다. 이를 통해 여러 요청이 동시에 만료된 캐시를 재계산하려는 캐시 스탬피드 현상을 방지합니다.

 

🔖 참고

https://toss.tech/article/25301

https://en.wikipedia.org/wiki/Cache_stampede

https://jhzlo.tistory.com/69

https://medium.com/@taesulee93/spring-data-redis-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-per-probabilistic-early-recomputation-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%BA%90%EC%8B%9C-%EC%8A%A4%ED%83%AC%ED%94%BC%EB%93%9C-%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0-275cac51e29e

 

 

 

 

'트러블슈팅' 카테고리의 다른 글

[Spring WebSocket] STOMP - convertAndSendToUser 알고 사용하기  (0) 2025.11.04
쿠키 Samesite 설정으로 이슈 해결하기  (0) 2025.10.19
Spring OAuth AccessToken 제대로 추출하기  (0) 2025.10.19
JpaRepository에서 default 키워드를 사용할 때 주의점  (0) 2025.10.19
'트러블슈팅' 카테고리의 다른 글
  • [Spring WebSocket] STOMP - convertAndSendToUser 알고 사용하기
  • 쿠키 Samesite 설정으로 이슈 해결하기
  • Spring OAuth AccessToken 제대로 추출하기
  • JpaRepository에서 default 키워드를 사용할 때 주의점
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    티스토리챌린지
    토스 페이먼츠
    데브코스
    자바
    JPA
    스프링
    백엔드 면접
    raid
    오블완
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
[Redis] 캐시 스탬피드 현상
상단으로

티스토리툴바