캐시 스탬피드 방지

조용현·2026년 3월 31일

문제 해결

목록 보기
9/15
post-thumbnail

배경

댓글 첫 페이지 캐싱을 적용한 뒤, 캐시가 올바르게 동작하는지 검증하기 위해 Slow Query Log를 분석했습니다. evict 직후 동시 요청을 보내보니 예상치 못한 결과가 나왔습니다.

# Time: 2026-03-28T13:50:38.814Z  Query_time: 0.091s  post_id = 804238
# Time: 2026-03-28T13:50:38.821Z  Query_time: 0.119s  post_id = 804238
# Time: 2026-03-28T13:50:38.821Z  Query_time: 0.106s  post_id = 804238
# Time: 2026-03-28T13:50:38.887Z  Query_time: 0.029s  post_id = 804238
# Time: 2026-03-28T13:50:39.390Z  Query_time: 0.058s  post_id = 804238
...

동일 postId에 대한 CTE 쿼리가 1건이 아니라 10~17건이 동시에 실행됐습니다. 캐시를 붙였음에도 인기 게시글 진입 시점에 DB로 쿼리가 폭발적으로 쏟아지는 상황이었습니다. 트래픽이 높아질수록 evict 직후 동시 요청 수가 늘어나고, DB CPU 스파이크가 전체 서비스 응답에 영향을 줄 수 있는 구조였습니다.


문제 원인

당시 getFirstPageCommon()은 다음 구조였습니다.

private CommentPageResponse getFirstPageCommon(Long postId) {
    Cache cache = cacheManager.getCache(COMMENT_FIRST_PAGE_CACHE);
    if (cache == null) return buildCommentPage(postId, 0);

    CommentPageResponse cached = cache.get(postId, CommentPageResponse.class);
    if (cached != null) return cached;

    CommentPageResponse fresh = buildCommentPage(postId, 0);
    cache.put(postId, fresh);
    return fresh;
}

cache.get(postId)cache.put(postId, value) 패턴은 두 연산이 분리되어 있습니다. 캐시가 evict된 직후 50개 스레드가 동시에 도달하면 모두 "캐시 없음"을 확인하고 각자 buildCommentPage()를 실행합니다. 이것이 캐시 스탬피드입니다.

이론적으로 50건인데 10~17건만 찍힌 이유

이론적으로 50개 요청이 모두 CTE를 실행할 수 있었지만, 실제로는 10~17건만 관측됐습니다. 이는 캐시 미스 구간의 길이, 요청 도달 타이밍, 커넥션 획득 시점 등이 겹치면서 일부 요청만 실제 재생성 경로에 들어갔기 때문으로 보입니다. 다만 이런 동작은 풀 크기와 동시성 상황에 따라 달라져 재현성이 낮고, 제어 가능한 해결책으로 보기 어렵습니다.


해결 과정

접근 1: synchronized 더블 체크 (검토 후 기각)

synchronized (this) {
    CommentPageResponse cached = cache.get(postId, CommentPageResponse.class);
    if (cached != null) return cached;

    CommentPageResponse fresh = buildCommentPage(postId, 0);
    cache.put(postId, fresh);
    return fresh;
}

경쟁을 막을 수는 있지만, synchronized (this)는 인스턴스 단위 락입니다. postId=1의 로딩이 끝날 때까지 postId=2, postId=3 요청도 전부 대기합니다. 스탬피드 범위는 "같은 key에 대한 동시 요청"인데 락 범위가 "모든 key에 대한 모든 요청"으로 넓어집니다.

접근 2: ConcurrentHashMap per-key 더블 체크 (검토 후 기각)

synchronized (this)의 문제는 락 범위가 넓다는 것이므로, postId별로 독립적인 락 객체를 관리하면 key 단위로 좁힐 수 있습니다.

private final ConcurrentHashMap<Long, Object> postLocks = new ConcurrentHashMap<>();

private CommentPageResponse getFirstPageCommon(Long postId) {
    Cache cache = cacheManager.getCache(COMMENT_FIRST_PAGE_CACHE);
    if (cache == null) return buildCommentPage(postId, 0);

    // 1차 확인 (락 없이) — 대부분의 요청은 여기서 반환
    CommentPageResponse cached = cache.get(postId, CommentPageResponse.class);
    if (cached != null) return cached;

    // 캐시 미스 시에만 per-key 락 진입
    Object lock = postLocks.computeIfAbsent(postId, k -> new Object());
    synchronized (lock) {
        // 2차 확인 — 대기하던 스레드들은 여기서 캐시 히트
        cached = cache.get(postId, CommentPageResponse.class);
        if (cached != null) return cached;

        CommentPageResponse fresh = buildCommentPage(postId, 0);
        cache.put(postId, fresh);
        return fresh;
    }
}

ConcurrentHashMap.computeIfAbsent()는 원자적이므로 같은 postId에 대해 항상 같은 Object 인스턴스를 반환합니다. postId=1 락과 postId=2 락이 분리되어 서로 영향을 주지 않습니다.

이 구현에는 두 가지 문제가 있었습니다.

문제 1: postLocks 메모리 누수

postId에 캐시 미스가 한 번이라도 발생하면 락 객체가 맵에 영구히 남습니다. evict 시 맵에서도 함께 제거하는 코드를 따로 관리해야 합니다.

문제 2: Redis 전환 시 효과 없음

이 방법은 단일 서버에서는 동작합니다. 그런데 추후 Redis로 전환하고 서버를 여러 대로 늘리면 postLocks는 각 JVM 안에만 존재합니다.

서버 A: postLocks { 804238 → Object@A }
서버 B: postLocks { 804238 → Object@B }  ← 완전히 다른 객체

→ 서버 A와 서버 B가 동시에 CTE 실행 → 스탬피드 재발

분산 환경에서 스탬피드를 막으려면 Redisson 같은 분산 락이 별도로 필요합니다. 즉 Redis 전환 시 어차피 락 구현을 교체해야 하므로, 지금 이 코드를 추가하는 것이 중간 단계에서 불필요한 복잡도만 늘립니다.

접근 3: Caffeine의 per-key 원자적 로딩 (최종 채택)

Spring Cache의 cache.get(key, Callable)은 캐시 미스 시 Callable을 실행해 값을 채우는 단일 메서드입니다. Caffeine 구현에서는 내부적으로 ConcurrentHashMap.computeIfAbsent()를 사용해 key 단위로 락을 분리합니다. 접근 2와 동일한 원리이지만, 락 객체 생명주기 관리를 Caffeine이 캐시 엔트리 생명주기에 맞춰 자동으로 처리합니다. Redis로 전환하면 해당 시점에 분산 락으로 교체합니다.

private CommentPageResponse getFirstPageCommon(Long postId) {
    Cache cache = cacheManager.getCache(COMMENT_FIRST_PAGE_CACHE);
    if (cache == null) return buildCommentPage(postId, 0);

    return cache.get(postId, () -> readOnlyTx.execute(status -> buildCommentPage(postId, 0)));
}

Callable을 readOnlyTx(TransactionTemplate)로 감싼 이유: buildCommentPage는 private 메서드여서 Spring 프록시가 @Transactional을 적용할 수 없습니다. TransactionTemplate은 프록시 없이 어느 스레드에서든 직접 트랜잭션을 열 수 있어, 향후 refreshAfterWrite로 전환해 Caffeine 백그라운드 스레드에서 Callable이 실행되더라도 트랜잭션 컨텍스트가 보장됩니다.

TransactionTemplateAppConfig에서 빈으로 등록해 주입받았습니다.

@Configuration
public class AppConfig {
    @Bean
    public TransactionTemplate readOnlyTx(PlatformTransactionManager transactionManager) {
        TransactionTemplate tx = new TransactionTemplate(transactionManager);
        tx.setReadOnly(true);
        return tx;
    }
}

동시성 테스트

JMeter 외에 단위 테스트 수준에서도 검증했습니다. 50개 스레드를 CountDownLatch로 동시에 출발시키고 실제 CTE 쿼리 실행 횟수를 카운팅했습니다.

CountDownLatch ready = new CountDownLatch(THREAD_COUNT);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger buildCount = new AtomicInteger(0);

// buildCommentPage 호출 시마다 buildCount 증가
// 50개 스레드가 동시에 getCommentPage 호출

assertEquals(1, buildCount.get(), "스탬피드 방지 실패: CTE 쿼리가 " + buildCount.get() + "회 실행됨");

수정 전에는 buildCount가 50이었고,

cache_stampede

수정 후에는 1이 고정됩니다.

cache_stampede_solved


테스트 결과

JMeter 테스트 구성

  • TG-Write (1 스레드): POST /boards/board-2/804238/comments/guest — 댓글 작성으로 캐시 evict 유발
  • TG-Read (50 스레드): SyncTimer(groupSize=50)로 동시 출발 → GET /boards/board-2/804238/comments?page=0

Slow Query Log 비교

수정 전 (evict 직후 50개 동시 요청)

지표수치
CTE 쿼리 실행 횟수10~17건 (HikariCP 여유 커넥션 수에 따라 비결정적)
실행 시간20~120ms

수정 후

# Time: 2026-03-30T21:14:01.825Z
# Query_time: 0.315905  Rows_sent: 100
WITH RECURSIVE comment_tree AS (...)
WHERE c.post_id = 804238 ...

# Time: 2026-03-30T21:14:02.151Z  ← 이후 49건
select c1_0.id, c1_0.like_count, c1_0.dislike_count from comment ...
수정 전수정 후
CTE 쿼리 실행 횟수10~17건 (비결정적)1건 (고정)
나머지 49건각자 CTE 실행캐시 히트 (like_count 합성만 수행)

evict 직후 50개 요청이 동시에 도달해도 CTE 쿼리는 1건만 실행됐습니다. 나머지 49건은 Caffeine per-key 락으로 대기했다가 캐시에서 결과를 받고, like_count/dislike_count 합성 쿼리만 각자 실행했습니다.

Slow Query Log 상세 분석

수정 전과 수정 후의 CTE 실행 시간을 Slow Query Log에서 비교했습니다.

수정 전 — 첫 번째 테스트 (09:19:21, 12건 중복 실행)

# Time: 09:19:21.090  Query_time: 0.002538
# Time: 09:19:21.096  Query_time: 0.002422
# Time: 09:19:21.125  Query_time: 0.002295
# Time: 09:19:21.145  Query_time: 0.020273
# Time: 09:19:21.146  Query_time: 0.041139
# Time: 09:19:21.146  Query_time: 0.048157
...

개별 CTE: 2~48ms (평균 ~20ms). 모두 같은 초에 실행되어 DB 리소스 경합이 발생했습니다.

수정 전 — 두 번째 테스트 (09:54:15, 17건 중복 실행)

# Time: 09:54:15.009  Query_time: 0.268009
# Time: 09:54:15.010  Query_time: 0.267604
# Time: 09:54:15.010  Query_time: 0.268810
# Time: 09:54:15.010  Query_time: 0.243011
# Time: 09:54:15.010  Query_time: 0.268306
# Time: 09:54:15.011  Query_time: 0.270361
...

개별 CTE: 2~272ms. 17개 CTE가 동시에 돌면서 vCPU 1개를 나눠 쓰기 때문에 경합이 심한 쿼리는 270ms까지 느려졌습니다.

수정 후 — (21:14:01, 1건만 실행)

# Time: 21:14:01.825  Query_time: 0.315905

CTE 1건만 실행: 315ms. 해결 전에도 경합 상태에서 개별 CTE가 270ms까지 걸렸으므로, 315ms는 CTE 자체가 느려진 것이 아니라 t2.micro(vCPU 1개) 환경의 리소스 한계입니다. 핵심은 17건 × 270ms의 총 DB 부하가 1건 × 315ms로 줄어든 것입니다.


트레이드오프

캐시 미스 시 50개 스레드 모두 Thread 1의 CTE 완료를 기다립니다. CTE 실행 시간(이번 테스트에서 315ms) 동안 나머지 49개 스레드가 블로킹되어 응답시간 스파이크가 발생합니다.

블로킹 없이 stale 값을 즉시 반환하려면 refreshAfterWrite + CacheLoader 조합으로 전환해야 합니다. TTL 만료 직후 첫 요청이 stale 값을 즉시 받고, 백그라운드에서 1회만 갱신하는 방식입니다. 댓글 첫 페이지는 수초의 stale이 허용되므로 유효한 선택지이며, readOnlyTx 빈도 이미 이를 위해 준비된 구조입니다. 현재는 레이턴시 스파이크가 실측 문제로 드러나지 않아 보류합니다.

단일 JVM에서만 유효하며, 분산 환경에서는 Redis 분산 락이 필요합니다.


결론

이 문제의 핵심은 캐시를 도입했는데도 캐시 미스 시점의 동시 접근을 제어하지 못해, cache.get → build → cache.put 구조가 오히려 같은 데이터를 여러 번 재생성하게 만든 데 있었습니다. 전역 락이나 직접 구현한 per-key 락 대신, Caffeine의 cache.get(key, Callable)이 제공하는 key 단위 원자적 로딩을 사용해 동일 postId에 대한 재생성을 1회로 제한했습니다.

그 결과 evict 직후 50개 동시 요청에서도 재귀 CTE는 10~17건이 아니라 1건만 실행됐고, 나머지 요청은 캐시 히트로 처리되어 DB 부하를 크게 줄일 수 있었습니다. 첫 번째 요청이 CTE를 수행하는 동안 다른 요청이 대기하는 블로킹 비용과, 단일 JVM 환경에 한정된다는 제약은 남아 있지만, 직접 락을 구현하기보다 라이브러리가 제공하는 원자적 로딩 추상화를 활용하는 편이 더 단순하고 유지보수에 유리했습니다.


참고

profile
백엔드 개발자

0개의 댓글