캐시 전략 패턴 — 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)
- 쓰기 후 읽기 사이에 짧은 불일치 구간 존재
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: 빠르지만 데이터 유실 위험
실전에서의 조합
실무에서는 하나의 전략만 쓰는 것이 아니라 상황에 맞게 조합합니다.
읽기: 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가 가장 범용적이고 안전한 선택입니다
- Write Through는 정합성이 중요할 때, Write Behind는 쓰기 성능이 중요할 때 사용합니다
- Refresh Ahead는 캐시 미스를 허용할 수 없는 핫 데이터에 적합합니다
- 쓰기 시에는 캐시 갱신보다 무효화가 더 안전합니다
- 실무에서는 여러 전략을 조합하여 사용하는 것이 일반적입니다
댓글 로딩 중...