Theme:

데이터를 캐시에 언제 넣고, 언제 갱신하고, DB와 어떻게 동기화해야 할까요?

캐시 전략 패턴이란

캐시 전략 패턴은 "캐시와 데이터베이스 사이에서 데이터를 어떻게 읽고 쓸 것인가"를 정의하는 설계 패턴입니다. 잘못된 전략을 선택하면 데이터 불일치, 불필요한 캐시 점유, 심지어 데이터 유실까지 발생할 수 있습니다.

왜 필요한가

  • DB 부하를 줄이면서도 데이터 정합성을 유지해야 합니다
  • 읽기 위주인지, 쓰기 위주인지에 따라 최적의 전략이 다릅니다
  • 장애 상황에서 데이터를 잃지 않아야 합니다

읽기 전략

1. Cache Aside (Lazy Loading)

가장 널리 사용되는 패턴입니다. 애플리케이션이 캐시와 DB를 직접 관리합니다.

PLAINTEXT
읽기:
1. 캐시에서 조회
2. 캐시 히트 → 바로 반환
3. 캐시 미스 → DB에서 조회 → 캐시에 저장 → 반환

쓰기:
1. DB에 쓰기
2. 캐시 무효화 (DELETE)
JAVA
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 조회를 대신 처리합니다. 애플리케이션은 항상 캐시만 바라봅니다.

PLAINTEXT
읽기:
1. 캐시에서 조회
2. 캐시 미스 → 캐시 라이브러리가 자동으로 DB 조회 → 캐시 저장 → 반환
JAVA
// 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 AsideRead Through
DB 조회 주체애플리케이션캐시 라이브러리
코드 복잡도캐시 로직이 비즈니스 코드에 섞임깔끔하게 분리
유연성세밀한 제어 가능프레임워크에 의존

쓰기 전략

3. Write Through

데이터를 쓸 때 캐시와 DB에 동시에 반영합니다.

PLAINTEXT
쓰기:
1. 캐시에 쓰기
2. DB에 쓰기 (동기)
3. 둘 다 성공해야 완료
JAVA
@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 반영은 비동기로 나중에 합니다.

PLAINTEXT
쓰기:
1. 캐시에 쓰기 → 즉시 응답
2. 별도 프로세스가 캐시 변경분을 모아서 DB에 배치 반영
JAVA
// 개념적 구현
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

캐시 만료 전에 미리 갱신합니다.

PLAINTEXT
동작:
1. TTL의 특정 비율(예: 80%)이 지나면 백그라운드에서 DB 조회 후 캐시 갱신
2. 사용자는 항상 캐시에서 바로 읽음
JAVA
// 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 (쓰기 시 캐시 무효화)

정합성 트레이드오프

PLAINTEXT
강한 정합성 ←────────────────→ 높은 성능
Write Through    Cache Aside    Write Behind
  • Write Through: 캐시 = DB, 하지만 쓰기가 느림
  • Cache Aside: 짧은 불일치 구간 존재, 균형 잡힌 선택
  • Write Behind: 빠르지만 데이터 유실 위험

실전에서의 조합

실무에서는 하나의 전략만 쓰는 것이 아니라 상황에 맞게 조합합니다.

PLAINTEXT
읽기: Cache Aside (대부분의 읽기)
  + Refresh Ahead (핫 데이터)

쓰기: Cache Aside (캐시 무효화)
  + Write Behind (로그성 데이터)

쓰기 시 캐시 갱신 vs 무효화

JAVA
// 방법 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는 캐시 미스를 허용할 수 없는 핫 데이터에 적합합니다
  • 쓰기 시에는 캐시 갱신보다 무효화가 더 안전합니다
  • 실무에서는 여러 전략을 조합하여 사용하는 것이 일반적입니다
댓글 로딩 중...