핵심 한 줄
11편에서 “코드는 그대로, 설정 한 줄로 선반(Caffeine→Redis)만 갈아끼운다”고 예고했다. 진짜 해보니 코드는 정말 안 고쳤지만, 선반이 바뀌자 로컬 캐시가 공짜로 주던 가정 셋이 차례로 터졌다 — ①직렬화(엔티티는
Serializable이 아니다) ②지연로딩(JSON으로 바꿔도 프록시가 문다) ③sync=true가 Redis에선 안 먹힌다. 로컬에선 공짜였던 것들이, 분산이 되는 순간 대가가 됐다. 그 대가를 알아야 분산 캐시를 진짜로 쓸 수 있다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. 선반만 바꾼다더니
11편에서 캐시를 **선반(저장 엔진)**과 **사서(프레임워크)**로 나눴다. Caffeine은 선반일 뿐, @Cacheable 코드는 사서에게 부탁만 하니, 선반을 Redis로 갈아끼워도 코드는 그대로여야 한다 — 고 했다.
왜 갈아끼우나? Caffeine은 각 JVM 안 로컬 선반이다. 서버를 2대로 늘리면 캐시도 2개, 한쪽에서 값을 바꿔 evict해도 다른 쪽은 옛값을 계속 내민다. 여러 인스턴스가 같은 선반을 보려면 별도 프로세스인 Redis가 필요하다.
설정은 정말 한 줄이었다.
spring:
cache:
type: redis # caffeine → redis. @Cacheable 코드는 한 줄도 안 건드림.그리고 기존 캐시 테스트를 그대로 돌렸다. 통과할 줄 알았다.
2. 첫 벽 — 직렬화
SerializationException. 캐싱하던 Order 엔티티를 Redis에 넣다가 터졌다.
이유는 로컬과 분산의 근본 차이다. Caffeine은 값을 객체 참조로 메모리에 둔다 — 직렬화가 없다. 그래서 엔티티를 캐싱해도 됐다. 하지만 Redis는 값을 네트워크 너머 바이트로 보내야 한다 — 직렬화가 필수다. 기본 직렬화기(JDK)는 값이 java.io.Serializable이길 요구하는데, Order는 아니다. 그래서 터졌다.
로컬 캐시가 공짜로 감춰주던 요구사항(직렬화)이, 분산이 되는 순간 청구서로 날아온 것이다.
3. 둘째 벽 — 지연로딩
“그럼 직렬화기를 JSON으로 바꾸면 되잖아?” 맞는 생각이다. Serializable을 요구하지 않는 JSON 직렬화기를 설정했다.
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
}다시 돌렸다. 이번엔 LazyInitializationException. Jackson이 Order를 JSON으로 만들며 지연로딩 컬렉션(orderLineList) getter를 건드렸는데, 그 시점엔 영속성 세션이 닫혀 있어 터진 것이다.
직렬화 방식(JSON)만 바꿨지, 진짜 문제는 안 풀렸다. 두 벽의 뿌리는 하나 — 엔티티를 캐싱하고 있다는 것. JPA 엔티티엔 지연 프록시와 객체 그래프가 딸려서, 애초에 경계 밖으로 나가기 부적합하다.
해법은 엔티티 말고 DTO를 캐싱하는 것이다.
public record OrderView(Long id) {} // 지연 프록시 없는 평평한 뷰
@Cacheable(value = "orders", key = "#id")
public OrderView findByIdCached(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return new OrderView(order.getId()); // 엔티티 → DTO
}이번엔 통과했다. Redis를 열어보니 이렇게 들어 있었다.
키: orders::239
값: {"@class":"com.kata.shop.application.OrderView","id":239}
이건 15편에서 배운 것과 똑같은 정신이다. 거기선 도메인↔영속성을 갈랐고, 여기선 영속성↔캐시를 갈랐다. “영속성 모델이 경계(REST·캐시·큐)로 새면 안 된다” — 오래 들어온 그 원칙을, 캐시라는 경계에서 두 벽으로 두들겨 맞고서야 몸으로 알았다.
4. 셋째 벽 — 떼가 몰린다
캐시가 비어 있는 순간(첫 요청, 또는 TTL 만료 직후)에 동시 요청이 몰리면 어떻게 될까. 콜드 캐시에 스레드 20개를 동시에 발사하고, 실제 DB로 간 횟수를 셌다.
>>> 동시 20요청, 실제 DB 로드 횟수 = 20
20 중 20. 캐시가 채워지기 전에 스무 개가 전부 미스해서, 다 같이 DB로 달려갔다. 인기 키 하나가 만료되는 순간 DB가 20배 부하를 맞는 것 — **캐시 스탬피드(thundering herd)**다.
5. sync=true의 배신
스프링엔 이걸 막는 스위치가 있다. @Cacheable(sync = true) — 미스일 때 한 스레드만 로드하고 나머지는 기다린다. 붙이고 다시 돌렸다.
>>> 동시 20요청, 실제 DB 로드 횟수 = 20
여전히 20. 안 먹혔다. 혹시나 해서 선반만 Caffeine으로 되돌려 같은 테스트를 돌리니 — 1이 나왔다.
처음엔 이렇게 결론 냈다 — sync=true는 캐시 구현이 원자적 로더를 지원해야 작동하는데, Caffeine은 지원(1)하고 RedisCache는 지원하지 않는다(20)고. 하지만 이 결론은 반쪽이었다. “정말 Redis는 안 되나?”라는 의심을 끝까지 밀어붙이자 진짜 원인이 나왔다.
범인은 RedisCache가 아니라 **캐시 라이터(cache writer)**였다. Spring Boot가 기본으로 쓰는 nonLockingRedisCacheWriter가 sync에 협조하지 않을 뿐, lockingRedisCacheWriter로 바꾸면 같은 Redis에서 DB 로드가 1로 접힌다.
RedisCacheWriter writer = RedisCacheWriter.lockingRedisCacheWriter(connectionFactory);
return RedisCacheManager.builder(writer).cacheDefaults(config).build();그리고 결정적으로, 이 락은 Redis에 저장된다. JVM 하나가 아니라 인스턴스 전체를 통틀어 하나만 로드하게 하는 — 별도 라이브러리(Redisson) 없이 기본기로 얻은 분산 단일 로더다. 공짜는 아니다. 캐시 미스마다 Redis에 락 왕복이 붙는다(단, 트래픽의 대부분인 캐시 히트 경로는 그대로 빠르다).
정리하면 Redis 스탬피드 대응은 이렇다.
- TTL 지터 — 만료 시각을 흩뿌려 떼가 애초에 안 몰리게. 캐시 구현과 무관, 싸다. 1순위.
- 락킹 라이터 / 분산 락 — 진짜 단일 로더가 필요하면.
lockingRedisCacheWriter(기본기)나 Redisson. 대가는 미스마다 락 왕복 지연. 로드가 정말 비쌀 때.
교훈이 하나 더 붙었다. 나는 “Redis는 sync가 안 된다”고 성급히 단정했지만, 그건 기본 설정만 보고 내린 반쪽 결론이었다. 문서든 실측이든 “왜”까지 파야 진실이 나온다.
6. 횟수는 완승, 시간은?
여기서 한 걸음 더 갔다. 지금까지 잰 건 DB 횟수(20→1)였다. 시간을 재보니 다른 그림이 나왔다.
락킹 sync의 락은 폴링 방식이라, 스무 스레드가 줄 서면 벽시계가 오히려 느려진다. 싼 로드(18ms)에선 20동시 총 572ms, 200ms짜리 로드에서도 868ms로 — 그냥 병렬로 때리는 것(~238ms)보다 느렸다. 횟수로는 완승인데 시간으로는 손해. 락킹 sync는 ‘속도’ 도구가 아니라 ‘백엔드 보호’ 도구였다.
그럼 언제 이기나? 병렬 로드가 공유 한계 자원을 고갈시킬 때다. 로드가 DB 커넥션을 붙잡게 하고(SELECT SLEEP) 커넥션 풀을 2로 좁히자, 그림이 뒤집혔다.
| 시나리오 | 락킹 sync | no-sync |
|---|---|---|
| 싼 로드(18ms) | 572ms / DB 1 | ~20ms / DB 20 |
| 비싼 로드(200ms), 자원 여유 | 868ms / DB 1 | 238ms / DB 20 |
| 비싼 로드 + 풀 고갈(2) | 841ms / DB 1 | 2138ms / DB 20 |
풀이 좁으면 no-sync 20병렬은 커넥션 고갈로 줄 서 2138ms로 폭발하고 DB를 20번 때린다. 락킹 sync는 커넥션 하나만 쓰며 841ms, DB 1번 — 여기서 비로소 시간까지 이긴다.
그래서 결정 기준은 “로드가 비싼가”가 아니라 **“병렬 로드가 공유 병목(커넥션 풀·외부 쿼터·CPU)을 무너뜨리는가”**다. 무너뜨리면 락킹 sync가 보호와 속도를 다 가져가고, 아니면 락 오버헤드만 손해다. 횟수만 봤으면 영영 몰랐을 결론 — 시간을 재야 트레이드오프가 보인다.
7. 공짜였던 것이 대가가 되다
세 벽은 결국 한 이야기다. 로컬 캐시(Caffeine)가 조용히 공짜로 주던 것들 — 직렬화 면제, 지연로딩과의 공존, 원자적 로더 — 이 분산(Redis)이 되는 순간 전부 청구서로 바뀌었다. 코드는 한 줄도 안 고쳤지만, 선반의 물리적 성질이 달라지자 숨어 있던 가정들이 드러난 것이다.
“설정 한 줄로 선반을 바꾼다”는 11편의 약속은 거짓이 아니었다 — 코드는 정말 안 고쳤다. 다만 선반이 바뀌면 그 선반의 대가를 새로 치러야 한다는 조항이 작은 글씨로 붙어 있었을 뿐이다. 추상화는 교체를 쉽게 해주지만, 물리 법칙까지 감춰주진 않는다.
카타는 계속된다. 지금까지는 “기술의 함정”을 주로 걸었다 — 다음엔 도메인 자체로 방향을 틀 차례다. 날짜 경계, 할인 배분의 잔돈, 상태머신의 전이 가드.
이 시리즈는 계속 이어진다.