시리즈 안내

1. 캐시란 무엇인가

2. Spring Cache 추상화

3. Caffeine 설정과 활용

4. LoadingCache와 캐시 스탬피드 ← 현재 문서

5. Actuator로 캐시 모니터링

캐시 스탬피드란

인기 게시글 목록을 캐시하고 있다고 하자. TTL이 만료되는 순간, 100개의 요청이 동시에 들어온다. 캐시가 없으니 100개 요청이 전부 DB로 동일한 쿼리를 날린다.

TTL 만료 → 캐시 미스
    → 요청 1: DB 조회 (SELECT ...)
    → 요청 2: DB 조회 (SELECT ...)  ← 같은 쿼리!
    → 요청 3: DB 조회 (SELECT ...)  ← 같은 쿼리!
    → ...
    → 요청 100: DB 조회 (SELECT ...)

이것이 캐시 스탬피드 (Cache Stampede)다. "Thundering Herd"라고도 부른다. 캐시가 만료된 순간 대량의 동일 요청이 DB를 동시에 강타하는 현상이다.

평소에 캐시가 잘 동작하다가, TTL이 끊기는 순간 DB에 순간적으로 부하가 폭증한다. 트래픽이 많은 서비스에서는 이 순간 DB가 과부하로 응답 불능에 빠질 수 있다.

일반 캐시의 문제

@Cacheable의 기본 동작은 캐시 스탬피드에 취약하다. TTL이 만료되는 순간, 대기하고 있던 모든 요청이 동시에 캐시 미스를 만나서 DB로 직행한다.

sequenceDiagram autonumber actor Users as 다수의 요청 (1~100) participant Cache as 일반 캐시 (@Cacheable) participant DB as 데이터베이스 Note over Users,DB: 🕒 TTL 만료 (캐시가 비워진 순간) Users->>Cache: 데이터 동시 요청 rect rgb(255, 230, 230) Cache-->>Users: ❌ 전원 캐시 미스 Note over Cache: 대기열 제어 장치가 없음 end Users->>DB: 💣 100개의 동일 쿼리가 DB로 동시 직행 Note over DB: 순간 부하 폭증 (Cache Stampede) DB-->>Users: 각각 결과 반환

조율 장치가 없다. 100개 요청이 오면 DB 쿼리가 100번 날아간다. 분홍 영역에서 보이듯 전원이 캐시 미스를 맞고, 그대로 DB로 직행한다. DB 응답이 느릴수록 대기 요청이 더 쌓이고, 더 많은 쿼리가 중복 실행된다.

LoadingCache의 해결법

Caffeine의 LoadingCache는 이 문제를 해결한다. 동일 키에 대한 첫 번째 요청만 DB를 조회하고, 나머지는 그 결과를 기다린다.

sequenceDiagram autonumber actor Users as 다수의 요청 (1~100) participant LC as LoadingCache participant DB as 데이터베이스 Note over Users,DB: 🕒 TTL 만료 (캐시 갱신 필요) Users->>LC: 데이터 동시 요청 rect rgb(232, 248, 232) Note over LC: 🔒 첫 1개 요청만 통과, 나머지 99개는 대기 LC->>DB: 단 1번만 DB 쿼리 실행 end DB-->>LC: 조회 결과 반환 rect rgb(240, 248, 255) Note over LC: 대기 중이던 99개 요청에 결과 공유 LC-->>Users: ✅ 100개 요청 모두 동일 결과로 반환 end

LoadingCache 내부에서 같은 키에 대한 동시 요청을 하나로 병합한다. 초록 영역에서 보이듯 첫 번째 요청만 로딩 함수를 실행하고 나머지는 완료될 때까지 블로킹된다. 결과가 돌아오면 파란 영역처럼 대기 중이던 요청 전부에 결과를 공유한다. DB 조회가 1번으로 줄어든다.

LoadingCache 설정

Spring Cache 추상화(@Cacheable)로는 LoadingCache를 직접 설정하기 어렵다. Caffeine API를 직접 사용해서 Java Config로 설정해야 한다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(PostRepository postRepository) {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(List.of(
            buildLoadingCache("popularPosts", 1, 300, key ->
                postRepository.findTop10ByOrderByViewCountDesc()
            ),
            buildSimpleCache("users", 200, 600),
            buildSimpleCache("notifications", 100, 60)
        ));
        return cacheManager;
    }

    private CaffeineCache buildLoadingCache(String name, int maxSize,
                                             int ttlSeconds, Function<Object, Object> loader) {
        return new CaffeineCache(name,
            Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterWrite(ttlSeconds, TimeUnit.SECONDS)
                .recordStats()
                .build(loader::apply)
        );
    }

    private CaffeineCache buildSimpleCache(String name, int maxSize, int ttlSeconds) {
        return new CaffeineCache(name,
            Caffeine.newBuilder()
                .maximumSize(maxSize)
                .expireAfterWrite(ttlSeconds, TimeUnit.SECONDS)
                .recordStats()
                .build()
        );
    }
}

Caffeine.newBuilder().build(loader::apply)loader가 핵심이다. 캐시 미스 시 이 함수가 호출되어 값을 로딩한다. 동일 키에 대한 동시 요청은 이 함수를 한 번만 실행하고 결과를 공유한다.

LoadingCache를 모든 캐시에 쓸 필요는 없다

캐시 스탬피드가 문제되는 건 트래픽이 많고, 동일 키에 동시 요청이 몰리는 캐시뿐이다. 인기 게시글 목록, 전체 사용자 목록 같은 "모든 사용자가 동시에 조회하는" 캐시에 적합하다. 사용자별 개인 데이터는 키가 분산되므로 일반 캐시로 충분하다.

refreshAfterWrite

TTL이 만료된 순간 캐시가 비어서 스탬피드가 발생한다. refreshAfterWrite는 다른 접근을 취한다. TTL이 지나면 캐시를 비우지 않고, 백그라운드에서 값을 갱신한다. 갱신 중에도 기존 값을 계속 반환한다.

Caffeine.newBuilder()
    .maximumSize(1)
    .refreshAfterWrite(300, TimeUnit.SECONDS)
    .expireAfterWrite(600, TimeUnit.SECONDS)
    .build(key -> postRepository.findTop10ByOrderByViewCountDesc());

캐시 항목이 시간에 따라 어떻게 동작하는지를 타임라인으로 보면 이렇다.

timeline title refreshAfterWrite(5분) + expireAfterWrite(10분) 동작 흐름 section 정상 갱신 흐름 0분 캐시 저장 : 모든 요청 캐시 히트 : 사용자는 빠른 응답 받음 5분 refresh 도달 : 히트 유지 (기존 값 반환) : 백그라운드 DB 조회 시작 : 사용자 응답 지연 없음 갱신 완료 : 새 값으로 캐시 교체 : 무중단 히트 유지 section 예외 방어 (안전장치) 10분 expire 도달 : 정상 갱신 중이면 만료 없음 : 갱신 계속 실패 시에만 캐시 제거

정상 갱신 흐름에서 보이듯, 5분이 지나도 캐시를 비우지 않고 기존 값을 반환하면서 백그라운드에서 비동기 갱신을 시작한다. 사용자는 응답 지연을 전혀 경험하지 않는다. 예외 방어 구간의 expireAfterWrite(10분)는 안전장치다. 정상적으로 갱신되고 있다면 아무 일도 일어나지 않지만, DB 장애 등으로 갱신이 계속 실패하면 10분 시점에 캐시를 제거한다.

refreshAfterWrite는 LoadingCache에서만 동작한다

refreshAfterWrite는 캐시 미스 시 값을 로딩할 함수가 필요하다. build() 대신 build(loader)LoadingCache를 만들어야 한다. 일반 캐시(build())에서는 설정해도 아무 효과가 없다.

정리 — 캐시 스탬피드 대응 전략

세 전략은 구현 복잡도와 스탬피드 방어 수준이 다르다. 트래픽 규모와 요구 사항에 따라 선택한다.

quadrantChart title 캐시 스탬피드 대응 전략 비교 x-axis "구현 단순" --> "구현 복잡" y-axis "스탬피드 취약" --> "스탬피드 방어" Cacheable: [0.1, 0.12] LoadingCache: [0.55, 0.72] refreshAfterWrite: [0.75, 0.9]
  • @Cacheable : 구현이 가장 단순하지만 스탬피드에 무방비다.
  • LoadingCache : 동일 키 동시 요청을 1개로 줄인다. 트래픽이 많은 공유 데이터에 적합.
  • refreshAfterWrite : 사용자가 캐시 미스를 경험하지 않는다. 중단 없는 서비스가 핵심일 때 선택.
전략방식적합한 상황
@Cacheable (기본)캐시 미스 시 모든 요청이 DB 조회트래픽 적음, 스탬피드 위험 낮음
LoadingCache캐시 미스 시 1개만 DB 조회, 나머지 대기동시 요청이 많은 공유 데이터
refreshAfterWriteTTL 후 기존 값 반환 + 백그라운드 갱신중단 없는 서비스가 중요한 경우

기본 미션에서는 @Cacheable만으로 충분하다. 캐시 스탬피드는 대규모 트래픽에서 발생하는 문제이므로, 개념을 이해해두고 필요할 때 적용하면 된다.

Q&A

캐시 스탬피드가 뭐고, 어떻게 해결하나요?

TTL이 만료되는 순간 다수의 요청이 동시에 캐시 미스를 만나서 DB에 동일 쿼리가 쏟아지는 현상이다.

해결 방법:

1. LoadingCache : 같은 키에 대해 하나의 스레드만 DB를 조회하고 나머지는 대기

2. refreshAfterWrite : TTL 후에도 기존 값을 반환하면서 백그라운드로 갱신

3. 확률적 조기 갱신 : TTL 만료 전에 일정 확률로 미리 갱신하는 고급 기법

함정 : "TTL을 아주 길게 잡으면 스탬피드가 안 생기지 않나요?" → 맞다. 하지만 데이터 신선도가 떨어진다. 스탬피드를 방지하면서 적절한 TTL을 유지하는 것이 목표다.

[!QUESTION] LoadingCache를 쓰면 스탬피드가 완전히 사라지나요?

동일한 키에 대한 동시 요청은 1개로 줄어든다. 하지만 다른 키에 대해 동시에 캐시 미스가 발생하면, 키별로 1개씩 DB 조회가 나간다. 예를 들어 100개의 다른 게시글이 동시에 TTL 만료되면 100개의 DB 조회가 발생한다. LoadingCache는 "같은 키 동시 요청"을 막는 것이지, 모든 DB 부하를 막지는 않는다.

[!QUESTION] refreshAfterWrite와 LoadingCache 중 어떤 걸 선택해야 하나요?

두 가지는 목적이 다르다. refreshAfterWrite는 사용자가 캐시 미스를 경험하지 않도록 하는 것이 목적이다. 캐시 만료 시 응답 지연 없이 기존 값을 반환하면서 백그라운드 갱신을 한다. LoadingCache는 캐시 미스가 발생했을 때 DB 부하를 줄이는 것이 목적이다. 함께 쓰면 둘 다 커버할 수 있다. build(loader)로 LoadingCache를 만들고 refreshAfterWrite를 설정하면 된다.