데이터를 캐시에 언제 넣고, 언제 갱신하고, 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)
  • 쓰기 후 읽기 사이에 짧은 불일치 구간 존재

Cache Aside에서 쓰기 시 캐시를 "갱신"이 아니라 "무효화(DELETE)"하는 이유가 있습니다. 두 요청이 동시에 업데이트할 때, DB에는 요청 B의 값이 최종 반영되었는데 캐시에는 요청 A의 값이 나중에 쓰여질 수 있기 때문입니다. 무효화하면 다음 읽기에서 DB의 최신 값을 가져오므로 이 문제가 발생하지 않습니다.

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: 빠르지만 데이터 유실 위험

함정/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를 조회하도록 방어해야 합니다.

실전에서의 조합

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

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읽기+쓰기요청된 데이터만 캐시, 쓰기 시 무효화가장 범용적, 읽기 위주
Read Through읽기캐시 라이브러리가 DB 조회 대행코드 깔끔하게 분리
Write Through쓰기캐시+DB 동시 쓰기강한 정합성 필요 시
Write Behind쓰기캐시만 쓰고 DB는 비동기쓰기 성능 최우선
Refresh Ahead읽기TTL 만료 전 미리 갱신캐시 미스 허용 불가
댓글 로딩 중...