"캐시를 쓰면 빨라진다"는 건 알지만, 캐시된 데이터가 원본과 달라지면 어떡하죠?

캐시는 성능을 극적으로 개선하지만, 데이터 일관성이라는 근본적인 과제를 동반합니다. 어떤 전략으로 캐시를 운영하느냐에 따라 일관성, 성능, 복잡도의 균형이 달라집니다.

캐시 전략 개요

캐시 전략은 "읽기 시 캐시를 어떻게 채우는가"와 "쓰기 시 캐시를 어떻게 갱신하는가"에 따라 분류됩니다.

Cache-Aside (Lazy Loading)

가장 널리 사용되는 패턴입니다. Spring의 @Cacheable이 바로 이 패턴을 구현합니다.

동작 방식

읽기:

  1. 캐시에서 데이터 조회
  2. 캐시 히트 → 캐시 데이터 반환
  3. 캐시 미스 → DB 조회 → 결과를 캐시에 저장 → 반환

** 쓰기:**

  1. DB에 데이터 갱신
  2. 캐시에서 해당 키 삭제 (무효화)
JAVA
@Service
public class ProductService {

    @Cacheable("products")
    public Product findById(Long id) {
        return productRepository.findById(id).orElseThrow();
    }

    @CacheEvict("products")
    public void update(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(request);
        productRepository.save(product);
        // 캐시 삭제 → 다음 조회 시 DB에서 읽어 캐시 갱신
    }
}

** 장점:**

  • 구현이 간단하고 직관적
  • 실제로 요청되는 데이터만 캐시 (메모리 효율적)
  • 캐시 장애 시 DB로 폴백 가능

** 단점:**

  • 첫 요청은 항상 캐시 미스 (Cold Start)
  • 캐시 만료 후 DB 부하 급증 가능 (스탬피드)

Write-Through

쓰기 시 캐시와 DB를 동기적으로 모두 갱신합니다.

동작 방식

** 쓰기:**

  1. 캐시에 데이터 저장
  2. DB에 데이터 저장
  3. 두 작업이 모두 성공해야 완료
JAVA
@Service
public class ProductService {

    @CachePut(value = "products", key = "#id")
    public Product update(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(request);
        return productRepository.save(product);
        // 반환값이 캐시에 저장됨 (CachePut)
    }
}

** 장점:**

  • 캐시와 DB 간 데이터 일관성이 잘 유지됨
  • 읽기 시 항상 캐시 히트 (Cache-Aside의 Cold Start 문제 없음)

** 단점:**

  • 쓰기 지연 증가 (캐시 + DB 모두 갱신)
  • 읽히지 않는 데이터도 캐시에 저장될 수 있음

Write-Behind (Write-Back)

캐시에 먼저 쓰고, DB에는 비동기로 나중에 반영합니다.

동작 방식

** 쓰기:**

  1. 캐시에 데이터 즉시 저장
  2. 변경 사항을 큐에 적재
  3. 백그라운드에서 주기적으로 DB에 반영

Spring의 기본 캐시 추상화로는 직접 구현하기 어렵고, 별도의 비동기 처리 로직이 필요합니다.

JAVA
@Service
public class ProductService {

    private final Cache productCache;
    private final AsyncWriteQueue writeQueue;

    public void update(Long id, Product product) {
        // 캐시에 즉시 반영
        productCache.put(id, product);
        // DB 반영은 비동기 큐에 위임
        writeQueue.enqueue(new WriteTask(id, product));
    }
}

캐시에 즉시 쓴 뒤, 비동기 워커가 주기적으로 큐를 소비하여 DB에 배치로 반영합니다.

JAVA
// 비동기 워커 — 주기적으로 큐를 소비하여 DB에 반영
@Scheduled(fixedRate = 5000)
public void flushToDatabase() {
    List<WriteTask> tasks = writeQueue.drain();
    // 배치로 DB에 저장
    productRepository.saveAll(tasks.stream().map(WriteTask::getProduct).toList());
}

** 장점:**

  • 쓰기 성능이 매우 빠름 (캐시만 갱신)
  • 다수의 쓰기를 배치로 처리하여 DB 부하 감소

** 단점:**

  • 캐시 장애 시 아직 DB에 반영되지 않은 데이터 유실 위험
  • 구현 복잡도가 높음
  • 캐시와 DB 간 일시적 불일치 발생

전략 비교

전략읽기 성능쓰기 성능일관성복잡도
Cache-Aside미스 시 느림보통보통낮음
Write-Through항상 빠름느림높음중간
Write-Behind항상 빠름매우 빠름낮음높음

캐시 스탬피드 문제와 해결

스탬피드란

인기 있는 데이터(핫 키)의 캐시가 만료되는 순간, 수백~수천 개의 동시 요청이 모두 캐시 미스를 겪고 DB로 쏟아지는 현상입니다.

PLAINTEXT
캐시 만료 시점:
  요청1 → 캐시 미스 → DB 조회
  요청2 → 캐시 미스 → DB 조회
  요청3 → 캐시 미스 → DB 조회
  ...
  요청1000 → 캐시 미스 → DB 조회  ← DB 과부하!

해결 방법 1: 분산 락 (Lock)

하나의 요청만 DB를 조회하고, 나머지는 캐시가 채워질 때까지 대기합니다.

JAVA
@Service
public class ProductService {

    private final RedisTemplate<String, String> redisTemplate;
    private final CacheManager cacheManager;

    public Product findById(Long id) {
        Cache cache = cacheManager.getCache("products");
        Product cached = cache.get(id, Product.class);

        if (cached != null) {
            return cached;
        }

        // 분산 락 획득 시도
        String lockKey = "lock:products:" + id;
        Boolean acquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

락 획득에 성공한 요청만 DB를 조회하고 캐시를 채웁니다. 실패한 요청은 잠시 대기 후 캐시를 다시 확인합니다.

JAVA
        // findById() 계속
        if (Boolean.TRUE.equals(acquired)) {
            try {
                // 락 획득 성공 → DB 조회 후 캐시 저장
                Product product = productRepository.findById(id).orElseThrow();
                cache.put(id, product);
                return product;
            } finally {
                redisTemplate.delete(lockKey);
            }
        } else {
            // 락 획득 실패 → 잠시 대기 후 캐시 재조회
            Thread.sleep(50);
            return findById(id);  // 재귀 호출 (실무에서는 재시도 횟수 제한 필요)
        }
    }
}

해결 방법 2: 사전 갱신 (Eager Refresh)

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

JAVA
// Caffeine의 refreshAfterWrite 활용
Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .refreshAfterWrite(8, TimeUnit.MINUTES)  // 8분 후 비동기 갱신 트리거
    .build(key -> loadFromDB(key));           // CacheLoader 필요

refreshAfterWrite는 만료 전에 비동기로 갱신을 트리거합니다. 갱신 중에도 기존 캐시 값을 반환하므로 스탬피드가 발생하지 않습니다.

해결 방법 3: TTL 지터 (Jitter)

모든 캐시가 동시에 만료되지 않도록 TTL에 랜덤 값을 추가합니다.

JAVA
private Duration randomizedTtl(Duration baseTtl) {
    long jitter = ThreadLocalRandom.current().nextLong(0, baseTtl.toSeconds() / 5);
    return baseTtl.plusSeconds(jitter);
}

// 기본 TTL 10분 + 0~2분 랜덤
cache.put(key, value, randomizedTtl(Duration.ofMinutes(10)));

캐시 무효화 전략

TTL 기반 무효화

가장 단순한 방법입니다. 설정한 시간이 지나면 자동으로 캐시가 만료됩니다.

  • ** 장점 **: 구현 간단, 결국에는 최신 데이터로 갱신됨
  • ** 단점 **: TTL 동안은 오래된 데이터를 반환할 수 있음

이벤트 기반 무효화

데이터가 변경될 때 이벤트를 발행하여 관련 캐시를 즉시 무효화합니다.

JAVA
// 데이터 변경 시 이벤트 발행
@Service
public class ProductService {

    private final ApplicationEventPublisher eventPublisher;

    @CacheEvict("products")
    public void update(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id).orElseThrow();
        product.update(request);
        productRepository.save(product);

        // 관련 캐시도 무효화하기 위한 이벤트
        eventPublisher.publishEvent(new ProductUpdatedEvent(id));
    }
}

이벤트를 수신하는 리스너에서 연관된 캐시(예: 상품 목록)도 함께 무효화합니다.

JAVA
@Component
public class ProductCacheInvalidator {

    @EventListener
    @CacheEvict(value = "productList", allEntries = true)
    public void onProductUpdated(ProductUpdatedEvent event) {
        // 상품 목록 캐시도 함께 무효화
    }
}

TTL + 이벤트 조합 (추천)

실무에서는 두 방식을 함께 사용하는 것이 가장 안전합니다.

  • ** 이벤트 **: 변경 즉시 캐시 무효화 (빠른 반영)
  • TTL: 이벤트 누락 시 안전장치 (결국 갱신됨)

실무 팁

  • 캐시 키는 ** 명확하고 예측 가능 **하게 설계하세요 (디버깅 편의)
  • null 값 캐싱 에 주의하세요 — DB에 없는 데이터를 반복 조회하는 것을 방지하되, TTL을 짧게 설정
  • 캐시 히트율을 모니터링 하고, 50% 미만이면 전략을 재검토하세요
  • 캐시 크기를 제한하지 않으면 메모리 누수 가 발생합니다 (반드시 maximumSize 설정)

주의할 점

1. Cache-Aside에서 DB 갱신과 캐시 삭제 사이에 불일치 구간이 존재한다

DB를 갱신하고 캐시를 삭제하기 전에 다른 요청이 들어오면 오래된 캐시 데이터를 읽게 됩니다. 반대로 캐시를 먼저 삭제하고 DB를 갱신하면, 그 사이에 다른 요청이 DB의 옛 데이터를 캐시에 다시 채울 수 있습니다. 완벽한 일관성이 필요하다면 분산 락이나 Double Delete 같은 추가 전략이 필요합니다.

2. null 값을 캐싱하지 않으면 Cache Penetration이 발생한다

존재하지 않는 데이터를 반복 조회하면 매번 캐시 미스가 발생하여 DB에 직접 쿼리가 갑니다. 공격자가 의도적으로 없는 키를 대량 요청하면 DB가 과부하에 빠질 수 있습니다. null 결과도 짧은 TTL로 캐싱하거나 블룸 필터로 사전 차단하는 방어가 필요합니다.

3. 캐시 키 설계를 잘못하면 의도치 않게 다른 사용자의 데이터가 반환될 수 있다

메서드 파라미터가 같지만 사용자 컨텍스트가 다른 경우(예: 같은 상품 ID지만 로그인 사용자별로 가격이 다른 경우), 캐시 키에 사용자 정보를 포함하지 않으면 다른 사용자의 캐시된 결과가 반환됩니다. 캐시 키에 어떤 정보가 포함되어야 하는지 정확히 설계해야 합니다.

정리

  • Cache-Aside 는 가장 보편적이고 구현이 간단하며, Spring의 @Cacheable이 이를 구현합니다
  • Write-Through 는 일관성이 중요한 경우, Write-Behind 는 쓰기 성능이 중요한 경우에 사용합니다
  • 캐시 스탬피드 는 분산 락, 사전 갱신, TTL 지터로 방어할 수 있습니다
  • 캐시 무효화는 TTL + 이벤트 기반 을 함께 사용하는 것이 실무에서 가장 안전합니다
댓글 로딩 중...