캐시를 어디에 둘지 정했으면, 이제 "어떻게 읽고 쓸 것인가"를 정해야 한다. 데이터를 읽을 때 캐시를 먼저 볼지, 쓸 때 캐시에도 함께 쓸지에 따라 성능과 정합성이 크게 달라진다.
읽기 전략
캐시에서 데이터를 읽는 패턴에는 크게 두 가지가 있다. 애플리케이션이 직접 캐시를 관리하는 방식과, 캐시 계층이 알아서 처리하는 방식이다.
Cache-Aside (Lazy Loading)
가장 널리 사용되는 캐시 읽기 패턴이다. 애플리케이션이 캐시를 직접 관리한다.
- 캐시를 먼저 확인한다
- 히트면 캐시에서 반환한다
- 미스면 DB에서 조회한 뒤 캐시에 저장하고 반환한다
Spring Cache의 @Cacheable이 이 패턴을 구현한다.
- 장점 : 구현이 단순하다. 캐시 장애 시 DB에서 직접 조회하는 것으로 자연스럽게 폴백된다. 실제로 요청된 데이터만 캐시에 올라가므로 메모리를 효율적으로 사용한다.
- 단점 : 첫 요청은 반드시 미스가 발생한다. 이 첫 번째 미스를 콜드 스타트라고 부른다. 서버 재시작 직후 대량의 요청이 몰리면 모든 요청이 DB로 가는 현상이 벌어질 수 있다.
Read-Through
Cache-Aside와 비슷하지만, 캐시 계층이 직접 DB를 조회한다. 애플리케이션은 항상 캐시에게만 물어보고, 캐시가 미스일 때 스스로 DB에서 데이터를 가져와 저장한다.
파란 영역을 보면, 캐시가 직접 DB를 호출한다. Cache-Aside에서는 애플리케이션이 이 역할을 했다.
- 장점 : 애플리케이션 코드가 단순해진다. 캐시 로직이 캐시 계층에 캡슐화된다.
- 단점 : 캐시 계층이 DB 접근 방법을 알아야 하므로 설정이 복잡하다. Spring Cache에서는 Caffeine의
LoadingCache가 이 패턴에 해당한다.
쓰기 전략
데이터를 수정할 때 캐시를 어떻게 처리할 것인가? 읽기 전략보다 선택지가 다양하고, 트레이드오프도 뚜렷하다.
Write-Through
데이터를 수정할 때 캐시와 DB에 동시에 쓴다. 캐시에 항상 최신 데이터가 유지된다.
- 장점 : 캐시와 DB의 데이터 일관성이 보장된다. 읽기 시 항상 최신 데이터를 캐시에서 바로 반환한다.
- 단점 : 매번 쓰기가 2번(캐시 + DB) 발생해서 쓰기 지연이 증가한다. 캐시에 써놨는데 아무도 읽지 않는 데이터라면 불필요한 낭비다.
Write-Behind (Write-Back)
수정 시 캐시에만 즉시 쓰고, DB에는 나중에 비동기로 반영한다.
- 장점 : 쓰기 응답이 매우 빠르다. DB 쓰기를 모아서 일괄 처리하면 DB 부하도 줄어든다.
- 단점 : 캐시에만 쓰고 DB에 반영되기 전에 캐시가 날아가면 데이터가 유실된다. 일관성 보장이 복잡하다.
Write-Around
수정 시 DB에만 직접 쓰고, 캐시는 건드리지 않는다. 다음에 읽기 요청이 오면 Cache-Aside 방식으로 캐시에 올린다.
- 장점 : 쓰기 후 캐시를 갱신하는 복잡한 로직이 불필요하다. 쓰기만 하고 읽히지 않는 데이터가 캐시 공간을 차지하지 않는다.
- 단점 : 수정 직후 읽기 요청이 오면 미스가 발생한다. TTL이 남아있는 동안은 stale data가 반환될 수 있다.
Cache-Aside + Write-Around + TTL이 가장 많이 쓰이는 조합이다. 읽기는 Cache-Aside로, 쓰기는 DB에만 하고 캐시는 TTL로 자연 만료시킨다. 필요하면 수정 시 해당 캐시를 명시적으로 삭제(@CacheEvict)한다. 이 조합이 가장 단순하면서도 대부분의 상황에서 충분히 동작한다.
전략별 비교
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의 약자다.
저장 후 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를 다운시킬 수 있다.
동시에 DB를 공격 end
방지 방법
sync = true:@Cacheable(sync = true)로 설정하면 같은 키에 대한 동시 요청 중 하나만 DB를 조회하고 나머지는 그 결과를 기다린다.- TTL 분산 : 모든 캐시의 TTL을 똑같이 설정하지 않고, 약간의 랜덤 오프셋을 추가해서 동시 만료를 방지한다.
- LoadingCache : Caffeine의
LoadingCache를 사용하면 자동으로 단일 로딩이 보장된다.
자주 하는 실수
@Cacheable을 붙이면 Cache-Aside가 자동 적용된다. 하지만 수정 시 캐시를 어떻게 처리할지 결정하지 않으면 stale data 문제가 발생한다. 읽기 전략만큼 쓰기 전략도 반드시 함께 설계해야 한다.
[!DANGER] Write-Behind를 이해 없이 도입하기
쓰기 성능이 좋다는 이유로 Write-Behind를 선택하면, 캐시 장애 시 데이터 유실이라는 치명적인 위험을 감수해야 한다. 결제, 정산 같은 중요 데이터에는 절대 사용하면 안 된다.
[!DANGER] 캐시 스탬피드를 고려하지 않기
TTL이 만료되는 시점에 대량 트래픽이 몰리면 DB가 순간적으로 과부하된다. 인기 데이터일수록 위험하다. sync = true 또는 LoadingCache를 사용해서 방지하라.