스프링 캐시 심화 (1/11)

다음 편: [캐시 가이드] 2. 캐시 계층과 유형

캐시 가이드 시리즈

01 캐시 기초

- 캐시의 개념과 동작 원리 ← 현재 편

- 캐시 계층과 유형

- 캐시 전략과 패턴

02 Spring Cache 핵심

- Spring Cache 추상화

- 캐시 어노테이션 완전 정복

- 캐시 키 전략과 조건부 캐싱

03 캐시 저장소

- CacheManager와 Caffeine

- Redis 분산 캐시

04 실전과 운영

- 캐시 일관성과 갱신 전략

- 안티패턴과 트러블슈팅

- 면접 대비

DB 쿼리 한 번에 50ms. 초당 1,000건의 요청이 들어오면 DB는 매 초 50,000ms 분량의 작업을 처리해야 한다. 같은 상품 정보를 1,000명이 동시에 조회하는데, 매번 디스크까지 내려가서 똑같은 데이터를 꺼내오는 건 낭비다. 한 번 가져온 결과를 가까운 곳에 저장해두고 재사용하면 응답 속도도 빨라지고, DB 부하도 줄어든다. 이 단순한 아이디어가 캐시의 전부다.

캐시란

캐시는 자주 사용하는 데이터를 빠르게 접근할 수 있는 임시 저장소에 보관하는 기법이다. 원본 데이터는 보통 디스크에 저장되어 있고 네트워크를 거쳐야 접근할 수 있지만, 캐시는 메모리처럼 훨씬 가까운 곳에 데이터를 복사해둔다.

식당을 떠올려보자. 주문이 들어올 때마다 장을 보러 나가면 손님은 한참을 기다려야 한다. 자주 쓰는 재료를 미리 냉장고에 넣어두면, 장을 보러 나가는 횟수가 줄고 조리 속도가 빨라진다. 여기서 냉장고가 캐시에 해당한다.

flowchart LR subgraph "캐시 없음" A1["클라이언트"] -->|"요청"| B1["서버"] B1 -->|"매번 조회"| C1[("DB")] C1 -->|"결과"| B1 B1 -->|"응답
~50ms"| A1 end subgraph "캐시 있음" A2["클라이언트"] -->|"요청"| B2["서버"] B2 -->|"1차 조회"| D2["캐시"] D2 -->|"Hit
~1ms"| B2 B2 -.->|"Miss일 때만"| C2[("DB")] C2 -.->|"결과"| B2 B2 -->|"응답"| A2 end

캐시에는 두 가지 본질적인 특성이 있다.

  • 속도 : 캐시는 원본보다 빠른 저장소를 사용한다. 메모리 접근은 디스크 접근보다 수만 배 빠르고, 네트워크 왕복도 없다.
  • 용량 제한 : 메모리는 디스크에 비해 용량이 작고 비싸다. 모든 데이터를 캐시에 넣을 수는 없으니, 어떤 데이터를 넣을지, 공간이 부족할 때 무엇을 버릴지 선택해야 한다. 이 제약이 캐시 설계를 복잡하게 만드는 근본적인 원인이다.
캐시는 어디에나 있다

캐시는 애플리케이션만의 개념이 아니다. CPU에는 L1/L2/L3 캐시가 있고, 브라우저에는 HTTP 캐시가 있고, DNS에도 캐시가 있다. 층위는 다르지만 원리는 동일하다 — "느린 곳에 매번 가지 말고, 빠른 곳에 복사해두자."

지역성 — 캐시가 효과를 내는 조건

아무 데이터나 캐시에 넣는다고 빨라지는 게 아니다. 캐시가 효과를 내려면 지역성이라는 조건이 필요하다. 지역성은 데이터 접근 패턴에서 나타나는 편향이다. 모든 데이터가 균등하게 요청되는 게 아니라 특정 데이터에 요청이 집중되는 현상을 말한다.

Locality — 영어로는 이렇게 표현한다.

시간 지역성

한 번 접근한 데이터는 가까운 시간 내에 다시 접근될 가능성이 높다.

쇼핑몰의 인기 상품 페이지를 생각해보자. 수많은 사용자가 같은 상품 정보를 반복적으로 조회한다. 한 번 DB에서 읽어서 캐시에 올려두면, 이후 수백, 수천 건의 요청을 DB 없이 처리할 수 있다.

반면 아무도 안 보는 3년 전 단종 상품 정보는 캐시에 올려봐야 한 번도 재사용되지 않는다.

공간 지역성

특정 데이터에 접근하면 그 주변 데이터도 곧 접근될 가능성이 높다.

사용자가 게시글을 읽으면 바로 다음에 댓글 목록을 조회할 가능성이 높다. 카테고리 목록을 조회했다면 특정 카테고리의 상품 목록을 이어서 볼 가능성이 높다.


이 두 가지 지역성 덕분에 전체 데이터의 일부만 캐시해도 대부분의 요청을 커버할 수 있다. 실제로 많은 서비스에서 전체 데이터의 20%만 캐시해도 80% 이상의 요청을 처리할 수 있다. 지역성이 강할수록 캐시의 효과는 극대화되고, 지역성이 낮은 데이터에 캐시를 적용하면 캐시 공간만 차지하고 히트는 나지 않아서 오히려 손해다.

캐시 히트와 미스

캐시의 동작은 두 가지 결과로 나뉜다.

Cache Hit

캐시에 요청한 데이터가 이미 존재하는 상태다. 원본 저장소를 거치지 않고 캐시에서 바로 반환한다. 빠르다.

Cache Miss

캐시에 요청한 데이터가 없는 상태다. 원본 저장소에서 데이터를 가져오고, 그 결과를 캐시에 저장한 뒤 반환한다.

sequenceDiagram autonumber participant Client as 클라이언트 participant App as 애플리케이션 participant Cache as 캐시 participant DB as DB Client->>App: 데이터 요청 App->>Cache: 캐시 조회 alt Cache Hit rect rgb(232, 248, 232) Cache-->>App: 캐시 데이터 반환 Note right of Cache: 원본 저장소 접근 없음
~1ms end else Cache Miss rect rgb(255, 243, 224) Cache-->>App: 데이터 없음 App->>DB: DB 조회 DB-->>App: 결과 반환 App->>Cache: 결과 저장 Note right of DB: 디스크 I/O + 네트워크
~50ms end end App-->>Client: 응답 반환

초록 영역이 Hit, 주황 영역이 Miss다. Miss가 발생하면 원본 저장소 조회에 더해 캐시 확인과 캐시 저장이라는 추가 단계가 생긴다. 캐시가 아예 없을 때보다 미스 시에는 약간 더 느려진다. 캐시의 효과는 미스 1번의 추가 비용 < 히트 N번의 절약이 성립할 때 나타난다.

이 흐름을 Cache-Aside 패턴이라고 부른다. Spring Cache의 @Cacheable이 기본적으로 이 패턴을 따른다.

Cache-Aside 패턴의 핵심

애플리케이션 코드가 캐시와 DB 사이의 중재자 역할을 한다. 캐시가 자동으로 DB를 채우는 게 아니라, 애플리케이션이 "캐시에 없으면 DB에서 가져와서 캐시에 넣는다"라는 로직을 수행한다. 이 구조 덕분에 캐시 장애가 발생해도 DB에서 직접 조회하는 것으로 자연스럽게 폴백된다.

캐시 적중률

캐시가 실제로 얼마나 효과적인지를 측정하는 핵심 지표가 캐시 적중률이다. Hit Ratio라고도 한다.

캐시 적중률 = Cache Hit 수 / 전체 요청 수 × 100

숫자로 보면 적중률의 차이가 얼마나 큰지 체감할 수 있다. 캐시 조회에 1ms, DB 조회에 50ms가 걸린다고 가정하자.

적중률 85%일 때 평균 응답 시간은 다음과 같다.

(0.85 × 1ms) + (0.15 × 50ms) = 0.85 + 7.5 = 8.35ms

캐시 없이 DB만 사용하면 50ms다. 8.35ms면 약 6배 빨라진 것이다.

적중률을 95%로 올리면 어떻게 될까.

(0.95 × 1ms) + (0.05 × 50ms) = 0.95 + 2.5 = 3.45ms

85%에서 95%로 적중률이 10% 올라갔을 뿐인데, 평균 응답 시간은 8.35ms에서 3.45ms로 줄었다. 적중률 10% 차이가 응답 시간 기준으로 약 2.4배 차이를 만든다.

xychart-beta title "캐시 적중률에 따른 평균 응답 시간" x-axis ["0%", "50%", "70%", "85%", "90%", "95%", "99%"] y-axis "평균 응답 시간 (ms)" 0 --> 55 bar [50, 25.5, 15.7, 8.35, 5.9, 3.45, 1.49]

적중률이 80% 이상이면 효과적인 캐시라고 볼 수 있다. 50% 미만이면 캐시의 의미가 거의 없다. 오히려 캐시 조회라는 추가 단계만 낭비하는 셈이다.

적중률 모니터링은 필수

캐시를 도입한 뒤에는 반드시 적중률을 측정하고 모니터링해야 한다. Caffeine의 recordStats()나 Spring Actuator로 측정할 수 있으며, 이 부분은 CacheManager와 Caffeine 편에서 자세히 다룬다.

캐시 대상 선정 기준

모든 데이터를 캐시하면 좋을 것 같지만, 현실은 그렇지 않다. 캐시는 메모리를 소비하고, 데이터 일관성 문제를 일으킬 수 있다. 어떤 데이터를 캐시할지 선택하는 것이 캐시 설계의 핵심이다.

좋은 캐시 대상

  • 읽기 빈도가 높고 쓰기 빈도가 낮은 데이터 : 코드 테이블, 카테고리 목록, 설정값 등이 대표적이다.
  • 계산 비용이 큰 데이터 : 여러 테이블을 조인하거나 복잡한 집계 쿼리를 실행하는 경우다.
  • 약간의 지연이 허용되는 데이터 : 날씨 정보처럼 실시간 정확성보다 빠른 응답이 더 중요한 경우다.

나쁜 캐시 대상

  • 실시간 정확성이 필수인 데이터 : 재고 수량, 결제 금액, 인증 토큰 등이다.
  • 쓰기가 읽기보다 많은 데이터 : 데이터가 자주 변경되면 캐시를 계속 갱신해야 한다.
  • 사용자별로 고유한 데이터 : 캐시 키가 사용자 수만큼 생성되어 메모리가 폭발할 수 있다.
    • 다만, 키를 잘 설계하고 만료 시간을 짧게 잡으면 캐시할 수도 있다.

캐시의 트레이드오프

캐시는 만능이 아니다. 성능을 얻는 대가로 복잡성을 감수해야 한다.

mindmap root((캐시 트레이드오프)) 성능 향상 응답 속도 개선 DB 부하 감소 비용 메모리 사용량 증가 코드 복잡성 증가 데이터 일관성 위험

데이터 일관성

캐시된 데이터와 원본 데이터가 달라지는 순간이 반드시 존재한다. 원본이 변경되었는데 캐시에는 여전히 옛날 데이터가 남아 있는 상태를 stale data라고 부른다. 이 문제를 완전히 없앨 수는 없다. 얼마나 오래된 데이터까지 허용할 것인지 정하는 것이 설계의 핵심이다.

메모리 사용량

무제한으로 캐시하면 메모리 부족으로 애플리케이션이 죽을 수 있다. 캐시 항목의 최대 크기만료 시간(TTL)을 반드시 설정해야 한다. TTL은 Time To Live의 약자로, 캐시 항목이 생성된 후 얼마나 오래 유지되는지를 의미한다.

TTL 설정 가이드라인

- 날씨 정보 : 5~10분

- 카테고리/코드 테이블 : 1~24시간

- 사용자 프로필 : 5~30분

- 설정값 : 1시간 이상

코드 복잡성

캐시를 도입하면 "데이터가 변경되면 캐시를 어떻게 갱신할까?"라는 새로운 문제가 생긴다. 이 복잡성이 성능 개선의 이점보다 크다면 캐시를 도입하지 않는 편이 낫다. "캐시를 쓸까 말까" 고민될 때는 먼저 측정하라.

캐시 무효화

캐시 무효화란 캐시에 저장된 데이터가 더 이상 유효하지 않을 때 이를 제거하거나 갱신하는 것이다.

"컴퓨터 과학에서 어려운 것은 딱 두 가지다. 캐시 무효화와 이름 짓기." — Phil Karlton

캐시 엔트리의 생명주기는 이런 순환을 반복한다.

stateDiagram-v2 [*] --> Empty : 초기 상태 Empty --> Cached : 캐시 미스 → 원본 조회 후 저장 Cached --> Stale : 원본 데이터 변경 Cached --> Expired : TTL 만료 Stale --> Empty : 명시적 삭제 Stale --> Cached : 갱신 Expired --> Empty : 만료 엔트리 제거 Expired --> Cached : 재조회 후 저장

대표적인 무효화 방법 세 가지를 간략히 정리한다. 각 전략의 구체적인 구현 패턴은 캐시 전략과 패턴에서 다룬다.

  • TTL 기반 만료 : 일정 시간이 지나면 자동 만료. 가장 단순하고 널리 쓰인다.
  • 명시적 삭제 : 데이터 수정 시 해당 캐시를 직접 삭제. 불일치 시간이 거의 없다.
  • 갱신 : 데이터 수정 시 캐시도 새 값으로 덮어쓰기. 캐시에 항상 최신값이 유지된다.

실무에서는 TTL을 기본 안전장치로 깔고, 수정 시 명시적 삭제를 추가하는 식으로 조합해서 사용하는 경우가 많다.

자주 하는 실수

모든 것을 캐시하려는 유혹

"캐시하면 빨라지니까 다 캐시하자"는 가장 흔한 실수다. 캐시 항목이 많아지면 메모리 사용량이 급증하고, 무효화 로직이 복잡해지며, 디버깅이 어려워진다. 캐시 대상은 측정 결과를 기반으로 선정해야 한다.

[!DANGER] TTL 없이 캐시하기

TTL을 설정하지 않으면 캐시 항목이 영원히 남는다. 데이터가 변경되어도 캐시에는 옛날 데이터가 계속 살아 있고, 메모리는 끝없이 쌓인다. 반드시 TTL과 최대 크기를 함께 설정해야 한다.

[!DANGER] 캐시 적중률을 측정하지 않기

캐시를 걸어놨는데 실제로 효과가 있는지 확인하지 않으면, 느린 코드에 불필요한 복잡성만 추가한 결과가 된다. 캐시 도입 전후로 적중률과 응답 시간을 반드시 비교해야 한다.