캐시 전략 — Cache-Aside, Write-Through, Write-Behind 패턴 비교
같은 데이터를 매번 DB에서 읽으면 느린 건 알겠는데, 캐시를 어떻게 관리해야 할까요?
캐시는 성능 최적화의 핵심이지만, "어떤 패턴으로 캐시를 관리할 것인가"에 따라 일관성, 성능, 장애 대응이 완전히 달라집니다. 각 패턴의 동작 원리와 트레이드오프를 정리해보겠습니다.
왜 캐시인가 — 지역성 원리
캐시가 효과적인 이유는 지역성 원리(Locality of Reference) 때문입니다.
- ** 시간적 지역성** — 최근 접근한 데이터는 곧 다시 접근할 가능성이 높음
- ** 공간적 지역성** — 인접한 데이터도 함께 접근할 가능성이 높음
웹 애플리케이션에서 예를 들면:
- 인기 상품 정보 → 수천 명이 반복 조회 (시간적 지역성)
- 사용자 프로필 + 설정 → 로그인 시 함께 조회 (공간적 지역성)
DB 조회는 수 밀리초~수십 밀리초, Redis 캐시 조회는 서브 밀리초. 이 차이가 트래픽이 많아지면 엄청난 성능 차이를 만듭니다.
Cache-Aside (Look-Aside) 패턴
** 가장 일반적인 패턴입니다.** 애플리케이션이 캐시를 직접 관리합니다.
읽기 흐름
1. 애플리케이션 → 캐시 조회
2-a. 캐시 히트 → 바로 응답
2-b. 캐시 미스 → DB 조회 → 캐시에 저장 → 응답
쓰기 흐름
1. 애플리케이션 → DB에 쓰기
2. 캐시에서 해당 키 삭제 (또는 갱신)
// Redis + Spring에서의 Cache-Aside 구현
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. 캐시 조회
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // 캐시 히트
}
// 2. 캐시 미스 → DB 조회
Product product = productRepository.findById(id)
.orElseThrow();
// 3. 캐시에 저장 (TTL 설정)
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
return product;
}
** 특징:**
- 구현이 간단하고 직관적
- 캐시 장애 시에도 DB 직접 조회로 서비스 유지 가능 (복원력)
- 최초 요청은 항상 느림 (Cold Start)
- ** 최종적 일관성(Eventual Consistency)** — 캐시와 DB 사이 짧은 불일치 구간 존재
Cache-Aside는 거의 모든 상황에서 올바른 출발점입니다. Redis, Memcached를 사용하는 대부분의 시스템이 이 패턴을 기본으로 합니다.
Write-Through 패턴
** 캐시와 DB에 동기적으로 동시에 쓰는 패턴입니다.**
쓰기 요청 → 캐시에 쓰기 → DB에 쓰기 → 응답
(동기) (동기)
** 특징:**
- ** 강한 일관성** — 캐시와 DB가 항상 동기화
- 쓰기 지연 증가 — 캐시 + DB 두 번 쓰기
- 읽히지 않는 데이터도 캐시에 저장 → 메모리 낭비 가능
** 적합한 도메인:**
- 금융 거래 — 잔액 불일치 허용 불가
- 헬스케어 — 환자 데이터 정확성 필수
- 재고 관리 — 초과 판매 방지
Write-Behind (Write-Back) 패턴
** 캐시에 먼저 쓰고, DB에는 비동기로 나중에 반영하는 패턴입니다.**
쓰기 요청 → 캐시에 쓰기 → 응답 (빠름!)
↓ (비동기)
DB에 쓰기
** 특징:**
- 쓰기 지연이 매우 낮음 — 캐시에만 쓰고 바로 응답
- DB 부하 감소 — 여러 쓰기를 모아서 배치 처리 가능
- ** 데이터 유실 위험** — 캐시 장애 시 DB에 반영 안 된 데이터 손실
- 최종적 일관성
** 적합한 도메인:**
- 소셜 미디어 좋아요/조회수 — 약간의 유실 허용 가능
- 실시간 분석 로그 — 속도가 정확성보다 중요
- IoT 센서 데이터 — 대량 쓰기 처리
Read-Through 패턴
Cache-Aside와 비슷하지만, ** 캐시 라이브러리가 DB 조회를 대신 처리 **합니다.
애플리케이션 → 캐시 조회
↓ (미스 시)
캐시가 직접 DB 조회 → 캐시에 저장 → 응답
Cache-Aside와의 차이:
- Cache-Aside: 애플리케이션이 DB 조회 로직을 가짐
- Read-Through: 캐시 계층이 DB 조회 로직을 가짐 (애플리케이션은 캐시만 바라봄)
패턴 비교 표
| 패턴 | 쓰기 지연 | 읽기 지연 | 일관성 | 캐시 장애 시 |
|---|---|---|---|---|
| Cache-Aside | - (캐시 무관) | 미스 시 높음 | 최종적 | DB로 폴백 가능 |
| Write-Through | 높음 (동기 2회) | 항상 낮음 | 강함 | 쓰기 실패 |
| Write-Behind | 매우 낮음 | 항상 낮음 | 최종적 | ** 데이터 유실** |
| Read-Through | - | 미스 시 높음 | 최종적 | 읽기 실패 |
캐시 무효화 전략
"컴퓨터 과학에서 어려운 것 두 가지: 캐시 무효화와 이름 짓기" — Phil Karlton
TTL (Time-To-Live)
가장 단순한 전략입니다. 일정 시간이 지나면 캐시가 자동으로 만료됩니다.
// 30분 후 자동 만료
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));
- 장점: 구현이 간단, 오래된 데이터가 자동 정리
- 단점: TTL 동안은 오래된 데이터를 응답할 수 있음
이벤트 기반 무효화
데이터가 변경될 때 이벤트를 발행하여 캐시를 즉시 무효화합니다.
// 상품 가격이 변경되면 캐시 무효화
@TransactionalEventListener
public void onProductUpdated(ProductUpdatedEvent event) {
redisTemplate.delete("product:" + event.getProductId());
}
- 장점: 실시간에 가까운 일관성
- 단점: 이벤트 발행 누락 시 캐시 불일치
실무에서는 TTL + 이벤트 기반 을 함께 사용합니다. 이벤트로 즉시 무효화하되, 만약을 위한 안전망으로 TTL을 설정합니다.
캐시 스탬피드(Stampede) 문제
인기 있는 캐시 키가 만료되는 순간, 대량의 요청이 동시에 DB를 조회하는 현상입니다.
키 만료 → 1000개 요청이 동시에 캐시 미스 → 1000개 DB 쿼리 → DB 과부하
해결 방법
1. 뮤텍스(Mutex) / 분산 락
// 하나의 요청만 DB 조회, 나머지는 대기
public Product getProductWithLock(Long id) {
String key = "product:" + id;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
String lockKey = "lock:" + key;
// 락 획득 시도
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
// 락 획득 성공 → DB 조회 후 캐시 갱신
Product product = productRepository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
redisTemplate.delete(lockKey);
return product;
}
// 락 획득 실패 → 잠시 대기 후 캐시 재조회
Thread.sleep(50);
return redisTemplate.opsForValue().get(key);
}
2. 사전 갱신 (Eager Refresh)
TTL 만료 전에 백그라운드에서 미리 캐시를 갱신합니다. TTL이 30분이면 25분 시점에 갱신을 시작합니다.
3. 랜덤 TTL
모든 키의 TTL을 동일하게 설정하면 동시 만료가 발생합니다. TTL에 랜덤 값을 더해 만료 시점을 분산시킵니다.
// 기본 30분 + 0~5분 랜덤
long ttlMinutes = 30 + ThreadLocalRandom.current().nextInt(5);
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(ttlMinutes));
정리
- Cache-Aside 가 거의 모든 경우의 올바른 출발점 — 단순하고 장애에 강함
- Write-Through 는 강한 일관성이 필요한 금융/의료 도메인에 적합
- Write-Behind 는 쓰기 성능이 중요하지만 데이터 유실을 감수할 수 있을 때
- 캐시 무효화는 TTL + 이벤트 기반 조합이 실무 표준
- 캐시 스탬피드는 분산 락, 사전 갱신, 랜덤 TTL로 방어