본문으로 건너뛰기
🌱 Seed

캐시 정합성 전략 영상을 보고

이영수|2026년 4월 8일|1분 읽기

개요

캐시에 대해 알아야 하는 기본적인 내용들이 있어서 정리한다.

캐시 정합성이 어려운 이유

상품 가격이 10,000 -> 12,000 원으로 변경되었다면?

  • DB 에는 12,000 원으로 업데이트 완료
  • 캐시에는 10,000 원으로 남아있을 수 있다.

-> 사용자는 10,000 원을 봤는데, 결제는 신 가격으로 처리될 수 있다!!

원인 분석

  • 캐시와 DB 는 별도의 저장소 - 두 곳을 원자적 업데이트는 사실상 불가능하다.
  • 네트워크 지연, 서버 장애, 동시 요청 등등 갱신 순서가 보장되지 않을 수 있다.

두 곳을 일관되게 유지 라는건 근본적으로 어렵다.

=> 그렇기에, 패턴별 트레이드오프 + 비즈니스 요구에 맞게 전략을 선택해야 한다.

Cache-Aside

image

가장 널리 사용되는 캐시 패턴 (Lazy Loading)

  • 읽기 : 캐시에서 먼저 조회, 없으면 DB 에서 읽고 캐시 저장 후 반환
  • 쓰기 : DB 를 먼저 업데이트하고, 캐시 삭제
  • TTL 을 설정해 캐시가 영원히 남는 걸 방지

캐시를 삭제하는 이유 : 업데이트시, DB 쓰기 & 캐시 쓰기 사이 정합성이 깨질 수 있다.
왜지..?

다음 읽기는 자연스럽게 최신 데이터로 캐시가 재생성된다.

나머지 캐시 전략

  • Read-Through : 읽기 동작, 캐시 Miss 시 자동으로 DB 조회

  • Write-Through : 쓰기 동작, 캐시에 쓰고, 동기적으로 DB에 쓰기

정합성은 높지만, 매번 쓰기마다 캐시 + DB 두 번 기록해 느리다.
(어차피 정합성 문제가 발생할 수 있다.)

  • Write-Behind : 쓰기 동작, 캐시에 쓰고, 비동기로 나중에 DB 반영

쓰기가 매우 빠르지만 캐시 장애 시 미반영 데이터가 유실될 수 있다.
유실되어도 괜찮은 데이터에만 적용해야 한다.

캐시 무효화 순서 역전 문제

일종의 캐시간 Race Condition

1. Thread A: DB 업데이트
2. Thread B: DB 조회, 구 데이터
3. Thread A: 캐시 삭제
4. Thread B: 캐시에 구 데이터 저장
=> 캐시에 구 데이터 잔존! (DB는 최신, 캐시는 구 데이터가 TTL 만료까지 유지)

DB 업데이트, 캐시 삭제 순서에 상관없이 가능성은 존재한다.

그래도, DB 먼저 업데이트 -> 캐시 삭제가 상대적으로 안전하다. - Source of Truth

Double Delete

public void updateProduct(Long id, ProductUpdateDto dto) {
    // 1. 캐시 선 삭제 -- 구 데이터 즉시 제거
    cache.deleteProduct(id);
 
    repo.updateProduct(id, dto);
 
    CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS)
        .execute(() -> cache.deleteProduct(id));
}
  • 1차 삭제 : 구 데이터 즉시 제거해 대부분 요청이 최신 데이터를 받게 유도
  • 2차 삭제 : 500ms 뒤 한 번 더 삭제해 레이스 컨디션으로 저장된 구 데이터 제거

지연 시간은 DB 복제 지연 + 캐시 쓰기 시간등을 고려하여 결정 (보통 300ms ~ 1s)

-> 지연 시간 사이 여전히 구 데이터 노출 가능 및 완벽하지 않다.
비교적 간단하게 구현가능한게 큰 장점

Cache Stampede

image

인기 데이터의 캐시가 만료되는 순간, 수많은 요청이 동시에 캐시 Miss 발생
모든 요청이 DB 로 몰려, 동일한 데이터 중복 조회 - DB 과부하

꼭 수많은 요청이 아니더라도 무거운 쿼리가 실행되는 것도 문제가 될 수 있다.

  • 일종의 Thundering Herd, 트래픽 높은 서비스에서 빈번히 발생 가능
  • 심하면 DB 에 큰 부하를 주고, 연쇄적으로 더 많은 캐시 Miss 를 발생시키는 악순환 발생 가능

분산 락

public Product getProductWithLock(Long id) {
	String key = "product:" + id;
	Product cached = cache.opsForValue().get(key);
	if (cached != null) return cached;
	// 분산 락으로 한 스레드만 DB 조회
	RLock lock = redisson.getLock("lock:" + key);
	try {
	  if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
		  cached = cache.opsForValue().get(key); // Double Check
		  if (cached != null) return cached;
		  Product product = repo.findById(id).orElseThrow();
		  cache.opsForValue().set(key, product, Duration.ofMinutes(30));
		  return product;
	  }
	} finally {
	  if (lock.isHeldByCurrentThread()) lock.unlock();
	}
	throw new RuntimeException("캐시 갱신 대기 초과");
}

분산 락을 통해 하나의 스레드만 DB 를 조회하는걸 보장한다.
나머지는 락 대기 후 캐시에서 읽는다.

  • 락 획득 후 Double Check 를 한다. - 다른 스레드가 이미 캐시 채웠을 수 있으므로 재확인

트레이드 오프가 확실하다. 캐시를 보장하기 위해
그 캐시를 저장하는 (일반적으로) 레디스에 추가적인 연산을 하는 것이기 때문이다.

PER 알고리즘

public Product getProductWithPER(Long id) {
      String key = "product:" + id;
      CacheEntry entry = getFromCache(key);
      if (entry == null) return fetchAndCache(id, key);
      long ttl = entry.getExpireAt() - System.currentTimeMillis();
      // beta * log(random) * computeTime 으로 조기 갱신 확률 계산
      double gap = entry.getComputeTime()
              * Math.log(Math.random());
      if (ttl + gap <= 0) {
          return fetchAndCache(id, key); // TTL 전에 확률적으로 갱신
      }
      return entry.getValue();
}
  • PER : Probabilistic Early Recomputation

TTL 만료 전 확률적으로 데이터를 미리 갱신하는 알고리즘이다.
만료 시점에 가까워질수록 갱신 확률이 높아져 자연스럽게 1~2개의 요청만 DB 를 조회하는걸 기대할 수 있다.

즉, 모든 요청이 보내는걸 확률적으로 피한것이다.

  • 락 없이 동작하므로 분산 락 방식보다 성능 오버헤드가 적다

TTL 전략, 트레이드오프

TTL 이 캐시 정합성과 성능의 균형을 결정하는 핵심적 파라미터이다.
(일종의 DB 인덱스 느낌)

정합성: 캐시 데이터와 DB 원본 데이터가 얼마나 일치하는지
캐시 히트율: 요청이 들어왔을 때 캐시에서 바로 응답할 수 있는 비율
DB 부하: 캐시 미스 시마다 DB 조회, ↔ 히트율

  • 1분 이하: 정합성 높음 / 캐시 히트율 낮음 / DB 부하 높음

실시간 데이터(재고, 좌석) 등에 적합

  • 5분~30분: 정합성 중간 / 캐시 히트율 높음 / DB 부하 중간

일반적 상품 정보, 사용자 프로필에 적합

  • 1~24시간 : 정합성 낮음 / 캐시 히트율 매우 높음 / DB 부하 낮음

변경 빈도가 낮은 데이터, 카테고리 & 설정등

  • TTL X : 정합성 높음 / 캐시 히트율 최고 / DB 부하 최저

변경 시 명시적 삭제, 정적 데이터들

  • 추가로, TTL 은 Jitter 를 적용하면 좋다. 모든 키가 동시 만료되는 걸 방지하는 랜덤 오프셋

캐시 워밍과 프리로딩

서버 기동시 캐시가 비어있다.
-> 모든 요청이 DB 로 직행!!! (Cold Start)

  • Warming : 서버 시작 시 인기 데이터 미리 캐시에 적재

헬스체크를 워밍 완료 후 정상으로 전환해야한다.

  • Lazy Loading : 최초 요청 시 캐시 적재, 이후 Hit
  • Schedule Warming : 주기적으로 인기 데이터 갱신
  • Shadow 배포 : 신규 서버에 기존 서버 트래픽 복제해 캐시 활성화

실전 체크리스트

캐시를 잘 적용하고 싶다면 해야할 것

image