핵심 한 줄
앞 편들은 어떻게 물을지(N+1·실행계획)를 다듬었다. 마지막 축은 아예 다시 묻지 않는 것이다 — 같은 트랜잭션 안이면 **1차 캐시(영속성 컨텍스트)**가, 트랜잭션을 넘으면 **2차/앱 캐시(
@Cacheable)**가 DB를 스킵한다. 캐시 엔진(선반)과 캐시 프레임워크(사서)는 다르고, 진짜 난제는 채우는 게 아니라 버리는 것이다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
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. 남은 마지막 축 — 반복해서 묻지 않기
9편에서 쿼리 수를 줄였고, 10편에서 쿼리 하나의 비용을 줄였다. 둘 다 “어떻게 물을 것인가”의 이야기였다. 그런데 가장 빠른 쿼리는 아예 실행하지 않은 쿼리다.
같은 주문을 한 요청 안에서 두 번, 한 화면에서 열 번, 하루에 수천 번 조회한다. 매번 DB를 치는 대신 한 번 얻은 답을 손에 쥐고 있다가 되돌려주면 된다. 그게 캐시다. 그리고 캐시는 한 층이 아니다 — 살아있는 범위가 다른 두 층이 있다.
2. 1차 캐시 — 트랜잭션이 곧 캐시다
첫 번째 층은 우리가 이미 켜 두고도 몰랐던 것이다. JPA의 영속성 컨텍스트(Persistence Context) 자체가 캐시다. 같은 트랜잭션 안에서 같은 id를 두 번 조회하면, 두 번째는 DB로 나가지 않고 컨텍스트가 들고 있던 엔티티를 그대로 준다.
FirstLevelCacheTest가 이걸 그대로 측정한다. em.clear()로 컨텍스트를 비우고 Hibernate Statistics를 초기화한 뒤, 같은 id를 두 번 findById 한다.
Order first = orderRepository.findById(id).orElseThrow(); // (1) DB SELECT
Order second = orderRepository.findById(id).orElseThrow(); // 1차 캐시 히트 -> SQL 없음
// >>> SQL수=1 동일인스턴스=true두 번 조회했는데 실행된 SQL은 1번이다. 게다가 first == second가 true — 복사본이 아니라 완전히 같은 인스턴스를 돌려준다. 이 동일성 보장이 그냥 성능 팁이 아니다. 하이버네이트가 트랜잭션 끝에서 “바뀐 필드”를 찾아내는 **변경 감지(dirty checking)**가 바로 이 위에 서 있다. 같은 인스턴스를 계속 추적하니까, 스냅샷과 비교해서 UPDATE를 만들 수 있는 것이다.
그리고 이제 9편의 그 장면이 설명된다 — N+1을 측정할 때 왜 em.clear()를 불렀나. 지우지 않으면 1차 캐시가 조회를 삼켜서, 정작 세고 싶던 DB 쿼리가 캐시 히트로 숨어버리기 때문이다. 1차 캐시는 끄는 게 아니라 켜져 있다는 걸 아는 것이 핵심이다.
3. 2차/앱 캐시 — 트랜잭션을 넘어 공유한다
1차 캐시엔 한계가 있다. 트랜잭션이 끝나면 같이 사라진다. 다음 요청은 빈 컨텍스트에서 시작하니, 어제 천 번 조회한 주문도 오늘 다시 DB를 친다.
트랜잭션을 넘어 살아남으려면 두 번째 층 — 애플리케이션 캐시가 필요하다. Spring의 @Cacheable은 메서드 결과를 캐시에 얹는다.
@Cacheable(value = "orders", key = "#id")
public Order findByIdCached(Long id) {
return orderRepository.findById(id).orElseThrow();
}동작은 단순하다. 미스면 메서드를 실행하고 그 반환값을 저장한다. 히트면 — 메서드 본문 자체를 아예 건너뛴다. findById가 호출조차 안 된다.
OrderCacheableTest는 일부러 @Transactional을 떼고 각 호출을 독립 트랜잭션으로 둔다. 1차 캐시가 가려버리면 순수한 2차 캐시를 못 보기 때문이다.
orderQueryService.findByIdCached(seededId); // 1st: 미스 -> DB
long afterFirst = stats.getPrepareStatementCount();
orderQueryService.findByIdCached(seededId); // 2nd: 히트 -> 메서드 실행 안 함
long afterSecond = stats.getPrepareStatementCount();
assertThat(afterFirst).isGreaterThanOrEqualTo(1); // 첫 조회는 DB를 쳤다
assertThat(afterSecond).isEqualTo(afterFirst); // 두 번째는 SQL 증가 0첫 조회 SQL 1, 두 번째 조회 SQL 증가 0. 트랜잭션이 갈렸는데도 두 번째는 DB를 아예 안 갔다.
4. 선반과 사서는 다르다
여기서 개념 하나를 못 박아야 한다. 많은 사람이 “Caffeine이 캐시고 Redis가 캐시”라고 뭉뚱그리는데, 실제로는 역할이 둘로 나뉜다.
- 저장 엔진 = 선반. Caffeine(로컬 메모리), Redis(분산). 데이터를 실제로 담아두는 곳.
- 캐시 프레임워크 = 사서. Spring Cache, Hibernate 2차 캐시. “이 키가 있나? 없으면 채우고, 있으면 꺼내줘”를 지휘하는 쪽.
Caffeine은 하이버네이트 캐시가 아니다. 사서가 골라 쓸 수 있는 여러 선반 중 하나일 뿐이다. 이 분리가 실무에서 갖는 힘은 이거다 — 코드(@Cacheable)는 그대로 두고, 설정 한 줄로 선반만 갈아끼울 수 있다.
# 단일 인스턴스 / 로컬
spring.cache.type=caffeine
# 여러 인스턴스가 공유해야 할 때 / 분산
# spring.cache.type=redisfindByIdCached는 자기가 Caffeine 위에 있는지 Redis 위에 있는지 모른다. 사서에게 부탁할 뿐이고, 선반이 어디냐는 운영이 정한다.
5. 프록시 함정 — 또 만났다
@Cacheable도 @Transactional과 똑같이 프록시로 동작한다. Spring이 빈을 감싼 대리인이 “캐시에 있나?”를 가로채는 구조다. 그래서 같은 함정이 그대로 재등장한다 — self-invocation(자기 클래스 내부 호출)에는 캐시가 안 걸린다.
같은 클래스 안에서 this.findByIdCached(id)를 부르면 프록시를 거치지 않고 곧장 본문으로 뛰어들어, 캐시는 조용히 무시된다. 3·4·6편의 동시성·멱등 편에서 봤던 그 프록시 함정과 완전히 같은 얼굴이다. 규칙도 같다 — 캐시가 걸린 메서드는 반드시 다른 빈에서 호출한다.
6. 진짜 난제 — 채우기가 아니라 버리기
@Cacheable은 읽기만 캐시한다. 그래서 데이터가 바뀌면 캐시는 곧바로 **거짓말(stale)**이 된다. 주문 금액을 9999로 바꿨는데 캐시는 옛 값을 계속 내미는 순간, 캐시는 성능 도구가 아니라 버그다.
해법은 쓰기 시점에 그 항목을 버리는 것 — @CacheEvict.
@CacheEvict(value = "orders", key = "#id")
@Transactional
public void update(Long id, Money total) {
Order order = orderRepository.findById(id).orElseThrow();
order.updateMoney(total);
}update가 끝나면 orders[id]가 캐시에서 제거되고, 다음 조회는 캐시가 비었으니 다시 DB를 친다. OrderCacheableTest가 이 흐름을 세 박자로 측정한다.
orderQueryService.findByIdCached(seededId); // 1st: 미스 -> DB (c1)
orderQueryService.findByIdCached(seededId); // 2nd: 히트 (c2 == c1)
orderQueryService.update(seededId, Money.won(9999)); // @CacheEvict
long beforeThird = stats.getPrepareStatementCount();
orderQueryService.findByIdCached(seededId); // 3rd: 다시 미스 -> DB
long afterThird = stats.getPrepareStatementCount();
assertThat(c2).isEqualTo(c1); // evict 전: 히트, 증가 0
assertThat(afterThird).isGreaterThan(beforeThird); // evict 후: SQL 다시 +1숫자로 읽으면 이렇다 — 히트일 땐 SQL 증가 0, evict 뒤 재조회는 SQL +1. 캐시가 정확히 무효화된 만큼만 DB로 되돌아간다.
컴퓨터 과학에서 어려운 건 딱 두 개다 — 캐시 무효화, 그리고 이름 짓기.
이 오래된 농담이 농담이 아닌 이유가 여기 있다. 캐시를 채우는 코드는 애노테이션 한 줄이다. 하지만 “언제, 무엇을 버려야 옛 값이 새어나가지 않나”는 도메인 전체를 알아야 답할 수 있다. 캐시의 난이도는 저장이 아니라 일관성에 있다.
다음
값 객체 하나에서 출발했다. 불변식을 세우고, 동시성과 멱등성을 다루고, 트랜잭션 밖으로 외부호출을 밀어내고, 쿼리 수와 실행계획을 깎고, 이제 캐시 두 층까지 왔다. “그럴 것이다”가 아니라 매 편 테스트와 실행계획으로 증명하며 여기까지 걸었다.
그런데 카타는 끝나지 않는다. 아직 손대지 않은 벽이 남아 있다 — 날짜 경계(반품 기한을 하루 차이로 틀리는 곳), 할인 배분의 잔돈(1원이 어디로 가야 하나), 주문 상태머신의 전이 가드(허용되지 않은 상태 변화를 누가 막나). 각각이 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.
이 시리즈는 계속 이어진다.