댓글 첫 페이지 캐싱을 적용한 뒤, 캐시가 올바르게 동작하는지 검증하기 위해 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개 요청이 모두 CTE를 실행할 수 있었지만, 실제로는 10~17건만 관측됐습니다. 이는 캐시 미스 구간의 길이, 요청 도달 타이밍, 커넥션 획득 시점 등이 겹치면서 일부 요청만 실제 재생성 경로에 들어갔기 때문으로 보입니다. 다만 이런 동작은 풀 크기와 동시성 상황에 따라 달라져 재현성이 낮고, 제어 가능한 해결책으로 보기 어렵습니다.
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에 대한 모든 요청"으로 넓어집니다.
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 전환 시 어차피 락 구현을 교체해야 하므로, 지금 이 코드를 추가하는 것이 중간 단계에서 불필요한 복잡도만 늘립니다.
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이 실행되더라도 트랜잭션 컨텍스트가 보장됩니다.
TransactionTemplate은 AppConfig에서 빈으로 등록해 주입받았습니다.
@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이었고,
수정 후에는 1이 고정됩니다.
POST /boards/board-2/804238/comments/guest — 댓글 작성으로 캐시 evict 유발GET /boards/board-2/804238/comments?page=0수정 전 (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 합성 쿼리만 각자 실행했습니다.
수정 전과 수정 후의 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 환경에 한정된다는 제약은 남아 있지만, 직접 락을 구현하기보다 라이브러리가 제공하는 원자적 로딩 추상화를 활용하는 편이 더 단순하고 유지보수에 유리했습니다.