캐시를 어디에 둘지 정했으면, 이제 "어떻게 읽고 쓸 것인가"를 정해야 한다. 데이터를 읽을 때 캐시를 먼저 볼지, 쓸 때 캐시에도 함께 쓸지에 따라 성능과 정합성이 크게 달라진다.

읽기 전략

캐시에서 데이터를 읽는 패턴에는 크게 두 가지가 있다. 애플리케이션이 직접 캐시를 관리하는 방식과, 캐시 계층이 알아서 처리하는 방식이다.

Cache-Aside (Lazy Loading)

가장 널리 사용되는 캐시 읽기 패턴이다. 애플리케이션이 캐시를 직접 관리한다.

  1. 캐시를 먼저 확인한다
  2. 히트면 캐시에서 반환한다
  3. 미스면 DB에서 조회한 뒤 캐시에 저장하고 반환한다
sequenceDiagram autonumber participant App as 애플리케이션 participant Cache as 캐시 participant DB as DB App->>Cache: 조회 alt 캐시 히트 Cache-->>App: 반환 else 캐시 미스 Cache-->>App: 없음 App->>DB: 조회 DB-->>App: 결과 App->>Cache: 저장 end

Spring Cache의 @Cacheable이 이 패턴을 구현한다.

  • 장점 : 구현이 단순하다. 캐시 장애 시 DB에서 직접 조회하는 것으로 자연스럽게 폴백된다. 실제로 요청된 데이터만 캐시에 올라가므로 메모리를 효율적으로 사용한다.
  • 단점 : 첫 요청은 반드시 미스가 발생한다. 이 첫 번째 미스를 콜드 스타트라고 부른다. 서버 재시작 직후 대량의 요청이 몰리면 모든 요청이 DB로 가는 현상이 벌어질 수 있다.

Read-Through

Cache-Aside와 비슷하지만, 캐시 계층이 직접 DB를 조회한다. 애플리케이션은 항상 캐시에게만 물어보고, 캐시가 미스일 때 스스로 DB에서 데이터를 가져와 저장한다.

sequenceDiagram autonumber participant App as 애플리케이션 participant Cache as 캐시 participant DB as DB App->>Cache: 조회 rect rgb(240, 248, 255) Note over Cache,DB: 캐시가 직접 관리 alt 캐시 히트 Cache-->>App: 반환 else 캐시 미스 Cache->>DB: 직접 조회 DB-->>Cache: 결과 Cache->>Cache: 저장 Cache-->>App: 반환 end end

파란 영역을 보면, 캐시가 직접 DB를 호출한다. Cache-Aside에서는 애플리케이션이 이 역할을 했다.

  • 장점 : 애플리케이션 코드가 단순해진다. 캐시 로직이 캐시 계층에 캡슐화된다.
  • 단점 : 캐시 계층이 DB 접근 방법을 알아야 하므로 설정이 복잡하다. Spring Cache에서는 Caffeine의 LoadingCache가 이 패턴에 해당한다.

쓰기 전략

데이터를 수정할 때 캐시를 어떻게 처리할 것인가? 읽기 전략보다 선택지가 다양하고, 트레이드오프도 뚜렷하다.

Write-Through

데이터를 수정할 때 캐시와 DB에 동시에 쓴다. 캐시에 항상 최신 데이터가 유지된다.

sequenceDiagram autonumber participant App as 애플리케이션 participant Cache as 캐시 participant DB as DB App->>Cache: 데이터 쓰기 Cache->>DB: DB에도 쓰기 DB-->>Cache: 완료 Cache-->>App: 완료 Note right of Cache: 캐시와 DB 모두 최신
  • 장점 : 캐시와 DB의 데이터 일관성이 보장된다. 읽기 시 항상 최신 데이터를 캐시에서 바로 반환한다.
  • 단점 : 매번 쓰기가 2번(캐시 + DB) 발생해서 쓰기 지연이 증가한다. 캐시에 써놨는데 아무도 읽지 않는 데이터라면 불필요한 낭비다.

Write-Behind (Write-Back)

수정 시 캐시에만 즉시 쓰고, DB에는 나중에 비동기로 반영한다.

sequenceDiagram autonumber participant App as 애플리케이션 participant Cache as 캐시 participant DB as DB App->>Cache: 데이터 쓰기 Cache-->>App: 즉시 완료 rect rgb(240, 248, 255) Note over Cache,DB: 비동기 반영 (일정 주기) Cache->>DB: 모아서 일괄 반영 DB-->>Cache: 완료 end
  • 장점 : 쓰기 응답이 매우 빠르다. DB 쓰기를 모아서 일괄 처리하면 DB 부하도 줄어든다.
  • 단점 : 캐시에만 쓰고 DB에 반영되기 전에 캐시가 날아가면 데이터가 유실된다. 일관성 보장이 복잡하다.

Write-Around

수정 시 DB에만 직접 쓰고, 캐시는 건드리지 않는다. 다음에 읽기 요청이 오면 Cache-Aside 방식으로 캐시에 올린다.

  • 장점 : 쓰기 후 캐시를 갱신하는 복잡한 로직이 불필요하다. 쓰기만 하고 읽히지 않는 데이터가 캐시 공간을 차지하지 않는다.
  • 단점 : 수정 직후 읽기 요청이 오면 미스가 발생한다. TTL이 남아있는 동안은 stale data가 반환될 수 있다.
실무에서 가장 흔한 조합

Cache-Aside + Write-Around + TTL이 가장 많이 쓰이는 조합이다. 읽기는 Cache-Aside로, 쓰기는 DB에만 하고 캐시는 TTL로 자연 만료시킨다. 필요하면 수정 시 해당 캐시를 명시적으로 삭제(@CacheEvict)한다. 이 조합이 가장 단순하면서도 대부분의 상황에서 충분히 동작한다.

전략별 비교

flowchart TD A{"우선순위?"} -->|읽기 성능| B["Cache-Aside +
Write-Around"] A -->|데이터 일관성| C["Read-Through +
Write-Through"] A -->|쓰기 성능| D["Write-Behind"] B --> B1["가장 단순하고 범용적"] C --> C1["캐시에 항상 최신 데이터"] D --> D1["쓰기 빠르지만 유실 위험"] style B fill:#E8F8E8,stroke:#4CAF50 style C fill:#E8F4F8,stroke:#2196F3 style D fill:#FFF3E0,stroke:#FF9800
전략읽기 성능쓰기 성능일관성복잡도
Cache-Aside + Write-Around좋음보통TTL에 의존낮음
Read-Through + Write-Through좋음느림보장됨중간
Write-Behind좋음매우 빠름위험높음

캐시 제거 정책

캐시 공간이 가득 차면 기존 항목을 제거해서 공간을 확보해야 한다. 어떤 항목을 제거할 것인지 결정하는 것이 제거 정책이다. Eviction Policy라고 부른다.

LRU

Least Recently Used. 가장 오래 사용되지 않은 항목을 먼저 제거한다. 최근에 사용된 데이터는 곧 다시 사용될 가능성이 높다는 시간 지역성에 기반한다.

대부분의 캐시 라이브러리가 기본 정책으로 사용한다. Caffeine도 LRU 기반이다.

LFU

Least Frequently Used. 사용 빈도가 가장 낮은 항목을 먼저 제거한다. 자주 쓰는 데이터는 앞으로도 자주 쓸 것이라는 가정이다.

LRU보다 정확할 수 있지만, 과거에 많이 쓰였다가 지금은 안 쓰이는 데이터가 남아있는 문제가 있다. Caffeine은 W-TinyLFU라는 변형을 사용해서 이 문제를 해결한다.

FIFO

First In First Out. 먼저 들어온 항목을 먼저 제거한다. 가장 단순하지만 접근 빈도를 고려하지 않으므로 효율이 떨어진다.

TTL/TTI

시간 기반 만료. TTL은 저장 시점부터의 경과 시간, TTI는 마지막 접근 시점부터의 경과 시간이다. TTI는 Time To Idle의 약자다.

flowchart LR subgraph TTL["TTL (Time To Live)"] direction TB A1["저장 시점"] -->|"10분 경과"| B1["만료"] Note1["접근 여부와 무관하게
저장 후 10분이면 만료"] end TTL ~~~ TTI subgraph TTI["TTI (Time To Idle)"] direction TB A2["마지막 접근"] -->|"10분 경과"| B2["만료"] Note2["접근할 때마다
타이머 리셋"] end style TTL fill:#E8F4F8,stroke:#2196F3 style TTI fill:#FFF3E0,stroke:#FF9800
제거 정책 선택 가이드

- 일반적인 경우 → LRU (대부분의 기본값)

- 특정 데이터의 접근 빈도가 극단적으로 다른 경우 → LFU 또는 W-TinyLFU

- 데이터 신선도가 중요한 경우 → TTL 기반

- Caffeine을 사용하면 내부적으로 W-TinyLFU를 사용하므로, 별도로 정책을 신경 쓸 필요가 적다.

캐시 스탬피드

캐시 스탬피드는 캐시가 만료되는 순간 대량의 요청이 동시에 DB로 몰리는 현상이다. Cache Stampede, Thundering Herd라고도 부른다.

인기 상품의 캐시가 TTL로 만료된 순간, 대기 중이던 수백 건의 요청이 동시에 DB 조회를 시도한다. 이 순간적인 부하가 DB를 다운시킬 수 있다.

sequenceDiagram autonumber participant R1 as 요청 1 participant R2 as 요청 2 participant R3 as 요청 N participant Cache as 캐시 participant DB as DB Note over Cache: TTL 만료! rect rgb(255, 230, 230) R1->>Cache: 조회 → Miss R2->>Cache: 조회 → Miss R3->>Cache: 조회 → Miss R1->>DB: SELECT ... R2->>DB: SELECT ... R3->>DB: SELECT ... Note right of DB: N개의 동일 쿼리가
동시에 DB를 공격 end

방지 방법

  • sync = true : @Cacheable(sync = true)로 설정하면 같은 키에 대한 동시 요청 중 하나만 DB를 조회하고 나머지는 그 결과를 기다린다.
  • TTL 분산 : 모든 캐시의 TTL을 똑같이 설정하지 않고, 약간의 랜덤 오프셋을 추가해서 동시 만료를 방지한다.
  • LoadingCache : Caffeine의 LoadingCache를 사용하면 자동으로 단일 로딩이 보장된다.

자주 하는 실수

전략을 선택하지 않고 그냥 @Cacheable만 붙이기

@Cacheable을 붙이면 Cache-Aside가 자동 적용된다. 하지만 수정 시 캐시를 어떻게 처리할지 결정하지 않으면 stale data 문제가 발생한다. 읽기 전략만큼 쓰기 전략도 반드시 함께 설계해야 한다.

[!DANGER] Write-Behind를 이해 없이 도입하기

쓰기 성능이 좋다는 이유로 Write-Behind를 선택하면, 캐시 장애 시 데이터 유실이라는 치명적인 위험을 감수해야 한다. 결제, 정산 같은 중요 데이터에는 절대 사용하면 안 된다.

[!DANGER] 캐시 스탬피드를 고려하지 않기

TTL이 만료되는 시점에 대량 트래픽이 몰리면 DB가 순간적으로 과부하된다. 인기 데이터일수록 위험하다. sync = true 또는 LoadingCache를 사용해서 방지하라.