캐시 전략 패턴 — Cache Aside, Write Through, Write Behind
데이터를 캐시에 언제 넣고, 언제 갱신하고, DB와 어떻게 동기화해야 할까요?
개념 정의
캐시 전략 패턴 은 캐시와 데이터베이스 사이에서 데이터를 어떻게 읽고 쓸 것인가를 정의하는 설계 패턴입니다. 잘못된 전략을 선택하면 데이터 불일치, 불필요한 캐시 점유, 심지어 데이터 유실까지 발생할 수 있습니다.
왜 필요한가
- DB 부하를 줄이면서도 데이터 정합성을 유지해야 합니다
- 읽기 위주인지, 쓰기 위주인지에 따라 최적의 전략이 다릅니다
- 장애 상황에서 데이터를 잃지 않아야 합니다
읽기 전략
1. Cache Aside (Lazy Loading)
가장 널리 사용되는 패턴입니다. 애플리케이션이 캐시와 DB를 직접 관리합니다.
읽기:
1. 캐시에서 조회
2. 캐시 히트 → 바로 반환
3. 캐시 미스 → DB에서 조회 → 캐시에 저장 → 반환
쓰기:
1. DB에 쓰기
2. 캐시 무효화 (DELETE)
public User getUser(Long userId) {
String key = "user:" + userId;
// 1. 캐시 조회
User cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // 캐시 히트
}
// 2. DB 조회
User user = userRepository.findById(userId).orElseThrow();
// 3. 캐시 저장
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
return user;
}
public void updateUser(Long userId, UserUpdateDto dto) {
// 1. DB 업데이트
userRepository.update(userId, dto);
// 2. 캐시 무효화
redisTemplate.delete("user:" + userId);
}
장점:
- 실제로 요청되는 데이터만 캐시에 올라감
- 구현이 직관적
- 캐시 장애 시 DB로 폴백 가능
** 단점:**
- 첫 요청은 항상 캐시 미스 (Cold Start)
- 쓰기 후 읽기 사이에 짧은 불일치 구간 존재
Cache Aside에서 쓰기 시 캐시를 "갱신"이 아니라 "무효화(DELETE)"하는 이유가 있습니다. 두 요청이 동시에 업데이트할 때, DB에는 요청 B의 값이 최종 반영되었는데 캐시에는 요청 A의 값이 나중에 쓰여질 수 있기 때문입니다. 무효화하면 다음 읽기에서 DB의 최신 값을 가져오므로 이 문제가 발생하지 않습니다.
2. Read Through
캐시 계층이 DB 조회를 대신 처리합니다. 애플리케이션은 항상 캐시만 바라봅니다.
읽기:
1. 캐시에서 조회
2. 캐시 미스 → 캐시 라이브러리가 자동으로 DB 조회 → 캐시 저장 → 반환
// Spring Cache 추상화로 Read Through 구현
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
// 캐시 미스 시에만 이 메서드가 실행됨
return userRepository.findById(userId).orElseThrow();
}
** 장점:**
- 애플리케이션 코드가 깔끔해짐
- 캐시 로직과 비즈니스 로직 분리
** 단점:**
- 캐시 라이브러리가 DB 접근 방법을 알아야 함
- Cache Aside와 마찬가지로 Cold Start 문제
Cache Aside vs Read Through
| 특성 | Cache Aside | Read Through |
|---|---|---|
| DB 조회 주체 | 애플리케이션 | 캐시 라이브러리 |
| 코드 복잡도 | 캐시 로직이 비즈니스 코드에 섞임 | 깔끔하게 분리 |
| 유연성 | 세밀한 제어 가능 | 프레임워크에 의존 |
쓰기 전략
3. Write Through
데이터를 쓸 때 캐시와 DB에 동시에 반영합니다.
쓰기:
1. 캐시에 쓰기
2. DB에 쓰기 (동기)
3. 둘 다 성공해야 완료
@CachePut(value = "users", key = "#userId")
public User updateUser(Long userId, UserUpdateDto dto) {
// DB 업데이트
User updated = userRepository.update(userId, dto);
// 반환값이 자동으로 캐시에 저장됨
return updated;
}
** 장점:**
- 캐시와 DB가 항상 일치 (강한 정합성)
- 읽기 시 캐시 미스가 적음
** 단점:**
- 쓰기 레이턴시 증가 (캐시 + DB 둘 다 기다림)
- 읽히지 않는 데이터도 캐시에 쓰여서 메모리 낭비 가능
- TTL을 설정하여 완화 가능
4. Write Behind (Write Back)
캐시에 먼저 쓰고, DB 반영은 비동기로 나중에 합니다.
쓰기:
1. 캐시에 쓰기 → 즉시 응답
2. 별도 프로세스가 캐시 변경분을 모아서 DB에 배치 반영
// 개념적 구현
public void updateUser(Long userId, UserUpdateDto dto) {
// 1. 캐시에만 즉시 반영
User updated = applyUpdate(userId, dto);
redisTemplate.opsForValue().set("user:" + userId, updated);
// 2. DB 반영 큐에 추가
writeQueue.add(new WriteTask("user", userId, updated));
}
// 별도 스레드에서 주기적으로 DB에 배치 반영
@Scheduled(fixedRate = 1000)
public void flushWriteQueue() {
List<WriteTask> batch = writeQueue.drain();
if (!batch.isEmpty()) {
batchRepository.bulkUpdate(batch);
}
}
** 장점:**
- 쓰기 레이턴시가 매우 낮음 (캐시에만 쓰면 끝)
- DB 쓰기를 배치로 모아서 처리하므로 DB 부하 감소
** 단점:**
- 캐시 장애 시 아직 DB에 반영되지 않은 데이터 유실 위험
- 구현 복잡도가 높음
- 데이터 일관성 보장이 어려움
5. Refresh Ahead
캐시 만료 전에 미리 갱신합니다.
동작:
1. TTL의 특정 비율(예: 80%)이 지나면 백그라운드에서 DB 조회 후 캐시 갱신
2. 사용자는 항상 캐시에서 바로 읽음
// Caffeine 캐시의 refreshAfterWrite 활용 예시
Cache<Long, User> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(30))
.refreshAfterWrite(Duration.ofMinutes(24)) // 80% 시점에 갱신
.build(userId -> userRepository.findById(userId).orElseThrow());
** 장점:**
- 캐시 미스가 거의 발생하지 않음
- 사용자 응답 시간이 일정
** 단점:**
- 예측이 빗나가면 불필요한 DB 조회 발생
- 구현 복잡도 높음
전략 선택 기준
읽기 패턴 기준
| 상황 | 추천 전략 |
|---|---|
| 읽기 많고 쓰기 적음 | Cache Aside |
| 항상 최신 데이터 필요 | Read Through + Write Through |
| 캐시 미스 허용 불가 | Refresh Ahead |
쓰기 패턴 기준
| 상황 | 추천 전략 |
|---|---|
| 데이터 일관성 중요 | Write Through |
| 쓰기 성능 중요 | Write Behind |
| 단순하게 구현 | Cache Aside (쓰기 시 캐시 무효화) |
정합성 트레이드오프
강한 정합성 ←────────────────→ 높은 성능
Write Through Cache Aside Write Behind
- Write Through: 캐시 = DB, 하지만 쓰기가 느림
- Cache Aside: 짧은 불일치 구간 존재, 균형 잡힌 선택
- Write Behind: 빠르지만 데이터 유실 위험
함정/Pitfall
1. 쓰기 후 캐시 갱신 vs 무효화의 동시성 문제
두 요청이 동시에 같은 키를 업데이트할 때, 캐시 갱신 방식은 DB와 캐시의 불일치를 만들 수 있습니다. 요청 A가 DB를 먼저 업데이트하고, 요청 B가 DB를 나중에 업데이트했지만 캐시에는 A의 값이 나중에 쓰여질 수 있기 때문입니다. 캐시 무효화(DELETE)가 더 안전합니다.
2. Write Behind에서 장애 시 데이터 유실
Write Behind는 캐시에만 쓰고 DB 반영을 미루기 때문에, Redis 장애 시 아직 DB에 반영되지 않은 데이터가 유실됩니다. 결제나 주문 같은 중요 데이터에는 사용하면 안 됩니다.
3. Cache Aside의 Cache Stampede
캐시가 만료되는 순간 수백 개의 요청이 동시에 DB를 조회하는 현상이 발생할 수 있습니다. TTL에 랜덤 지터(jitter)를 추가하거나, 락을 걸어 하나의 요청만 DB를 조회하도록 방어해야 합니다.
실전에서의 조합
실무에서는 하나의 전략만 쓰는 것이 아니라 상황에 맞게 조합합니다.
읽기: Cache Aside (대부분의 읽기)
+ Refresh Ahead (핫 데이터)
쓰기: Cache Aside (캐시 무효화)
+ Write Behind (로그성 데이터)
쓰기 시 캐시 갱신 vs 무효화
// 방법 1: 캐시 무효화 (권장)
public void updateUser(Long userId, UserUpdateDto dto) {
userRepository.update(userId, dto);
redisTemplate.delete("user:" + userId); // 다음 읽기에서 DB에서 가져옴
}
// 방법 2: 캐시 갱신
public void updateUser(Long userId, UserUpdateDto dto) {
User updated = userRepository.update(userId, dto);
redisTemplate.opsForValue().set("user:" + userId, updated);
}
캐시 무효화가 더 안전합니다. 캐시 갱신은 동시 쓰기 시 DB와 캐시 간 불일치가 발생할 수 있기 때문입니다. (두 요청이 동시에 업데이트하면 DB는 B 값인데 캐시는 A 값이 될 수 있음)
정리
| 전략 | 읽기/쓰기 | 핵심 특성 | 적합 상황 |
|---|---|---|---|
| Cache Aside | 읽기+쓰기 | 요청된 데이터만 캐시, 쓰기 시 무효화 | 가장 범용적, 읽기 위주 |
| Read Through | 읽기 | 캐시 라이브러리가 DB 조회 대행 | 코드 깔끔하게 분리 |
| Write Through | 쓰기 | 캐시+DB 동시 쓰기 | 강한 정합성 필요 시 |
| Write Behind | 쓰기 | 캐시만 쓰고 DB는 비동기 | 쓰기 성능 최우선 |
| Refresh Ahead | 읽기 | TTL 만료 전 미리 갱신 | 캐시 미스 허용 불가 |