이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Cache Aside, Read Through, Write Through는 책임 위치가 어떻게 다른가?
  • 어떤 패턴이 정합성, 장애 격리, 코드 복잡도를 어떻게 바꾸는가?
  • Spring 백엔드에서 Cache Aside를 구현할 때 어떤 실패 경로를 반드시 처리해야 하는가?

개요

캐시 패턴은 “Redis를 어디에 붙일까?”가 아니라 “누가 cache miss, cache fill, write 반영, 장애 fallback을 책임질까?”를 정하는 설계다.

상품 상세 조회를 예로 들면 DB 직접 조회는 가장 단순하고 정합성 설명도 쉽다. 하지만 read peak가 커지면 DB가 같은 데이터를 반복해서 읽는다. 캐시를 넣으면 응답 시간과 DB 부하는 낮아지지만, 캐시에 없는 경우와 캐시에 오래된 값이 있는 경우를 새로 설계해야 한다.

이 문서는 Cache Aside, Read Through, Write Through를 구현 방식보다 책임 위치와 trade-off 기준으로 비교한다.

Cache Aside

Cache Aside는 애플리케이션이 캐시를 옆에 두고 직접 관리하는 방식이다.

1. 애플리케이션이 Redis에서 key를 조회한다.
2. hit이면 캐시 값을 반환한다.
3. miss이면 DB를 조회한다.
4. DB 결과를 Redis에 저장한다.
5. 사용자에게 결과를 반환한다.

Spring 코드에서는 다음처럼 보일 수 있다.

public ProductDetail getProductDetail(Long productId) {
    String key = "product:detail:" + productId;
 
    ProductDetail cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
 
    ProductDetail detail = productRepository.findDetail(productId)
        .orElseThrow(ProductNotFoundException::new);
 
    redisTemplate.opsForValue().set(key, detail, Duration.ofMinutes(5));
    return detail;
}

이 구조의 장점은 단순함이다. Redis가 실패하면 DB로 fallback할 수 있고, 캐시할 대상을 애플리케이션이 세밀하게 고를 수 있다.

단점도 애플리케이션 책임이다. 같은 key가 동시에 miss되면 여러 요청이 DB로 몰린다. DB 조회 후 Redis 저장에 실패하면 다음 요청도 다시 miss된다. DB 업데이트 후 캐시 삭제를 잊으면 오래된 값이 남는다.

Read Through

Read Through는 애플리케이션이 캐시를 데이터 소스처럼 사용하고, 캐시 계층이 miss 시 원본 저장소에서 값을 가져오는 방식이다.

Application -> Cache Provider -> DB Loader

애플리케이션 입장에서는 cache.get(key)만 호출하는 것처럼 보인다. miss 처리와 fill은 캐시 계층 또는 loader가 맡는다.

장점은 캐시 접근 코드가 단순해지고 miss 처리 정책이 한곳에 모인다는 점이다. 단점은 캐시 계층이 원본 DB 접근 방법을 알아야 하며, 장애 분석 때 애플리케이션과 캐시 provider 경계가 더 복잡해진다는 점이다.

Java/Spring 일반 웹 서비스에서는 명시적인 Cache Aside가 더 흔하다. Read Through는 캐시 제품이나 라이브러리가 loader 패턴을 잘 지원하고, 팀이 그 추상화를 운영할 준비가 있을 때 검토한다.

Write Through

Write Through는 쓰기 요청이 원본 저장소와 캐시를 함께 갱신하는 방식이다.

Write request
  -> cache update
  -> DB update
  -> response

또는 캐시 계층이 write를 받아 DB까지 반영하는 구조로 설명되기도 한다.

이 방식은 쓰기 직후 읽기에서 최신 캐시를 볼 가능성을 높인다. 하지만 쓰기 latency가 커지고, DB는 성공했는데 캐시 갱신이 실패한 경우나 캐시는 갱신됐는데 DB 쓰기가 실패한 경우를 처리해야 한다.

대부분의 Spring 백엔드에서는 핵심 쓰기 경로에서 DB를 최종 원천으로 두고, 캐시는 삭제하거나 이벤트로 무효화하는 방식이 더 단순하다.

패턴별 판단 표

패턴장점비용적합한 경우
DB 직접 조회정합성 설명이 쉽고 운영 구조가 단순하다read peak가 DB로 직접 간다트래픽이 낮거나 최신성이 매우 중요할 때
Cache Aside도입이 쉽고 fallback이 명확하다miss 폭주와 무효화 책임이 앱에 있다읽기 많고 약간의 stale을 허용할 때
Read Throughmiss 처리 정책이 캐시 계층에 모인다loader와 캐시 인프라 의존성이 커진다캐시 provider가 loader를 안정적으로 지원할 때
Write Through쓰기 직후 캐시 최신성이 높다write latency와 실패 조합이 늘어난다읽기 직후 최신 캐시가 중요하고 write QPS가 감당 가능할 때

패턴 선택의 기준은 성능만이 아니다. 팀이 장애를 어디에서 관측하고 복구할 수 있는지도 선택 기준이다.

요구사항에서 패턴으로

다음 질문에 답하면 패턴이 좁혀진다.

  • 같은 데이터를 얼마나 자주 읽는가?
  • 사용자가 오래된 값을 몇 초까지 허용할 수 있는가?
  • 쓰기 직후 같은 사용자가 바로 읽는가?
  • DB 장애 시 오래된 값이라도 보여줄 것인가?
  • Redis 장애 시 DB fallback을 허용할 것인가?
  • 캐시 무효화 실패를 감지할 지표가 있는가?

상품 설명은 Cache Aside와 긴 TTL로 충분할 수 있다. 가격은 TTL을 짧게 두거나 가격 변경 이벤트로 invalidation해야 한다. 권한 정보는 캐시하더라도 매우 짧은 TTL과 강한 무효화가 필요하다.

Spring Cache 추상화 사용 시 주의

Spring의 @Cacheable은 편하지만, 설계 결정을 숨길 수 있다.

@Cacheable(cacheNames = "productDetail", key = "#productId")
public ProductDetail getProductDetail(Long productId) {
    return productRepository.findDetail(productId)
        .orElseThrow(ProductNotFoundException::new);
}

이 코드는 짧지만 다음 질문에 답하지 않는다.

  • TTL은 어디에서 설정되는가?
  • null 결과를 캐시할 것인가?
  • key 이름 충돌은 없는가?
  • DB 업데이트 시 @CacheEvict가 같은 key를 정확히 지우는가?
  • Redis 장애 시 예외를 던질 것인가, DB로 fallback할 것인가?

@Cacheable을 쓰더라도 캐시 정책 문서는 따로 있어야 한다.

장애 경로

Cache Aside의 장애 경로는 최소 세 가지다.

  • Redis read 실패: DB로 fallback할지, 요청을 실패시킬지 정한다.
  • DB read 실패: stale cache를 반환할 수 있는지 정한다.
  • Redis write 실패: 사용자 응답은 성공으로 둘지, 다음 요청의 miss를 감수할지 정한다.

상품 상세처럼 read-only 성격이 강한 데이터는 Redis 장애 시 DB fallback이 자연스럽다. 그러나 DB가 이미 과부하라 Redis 장애가 곧 DB 장애로 이어질 수 있다. fallback은 항상 안전한 것이 아니다.

위험 신호!

  • 캐시 패턴을 정하지 않고 @Cacheable만 붙인다.
  • DB 업데이트와 캐시 무효화가 서로 다른 코드 경로에 흩어져 있다.
  • Redis 장애 시 DB fallback이 전체 DB 장애로 번질 수 있는지 계산하지 않는다.
  • null 값이나 존재하지 않는 key에 대한 정책이 없다.
  • cache hit ratio만 보고 stale data와 invalidation 실패를 보지 않는다.

확인 질문

확인 질문

  • 확인 질문: Cache Aside에서 애플리케이션이 책임지는 것은 무엇인가?
    • 답변: cache lookup, miss 시 DB 조회, cache fill, DB 변경 후 invalidation, Redis 장애 fallback이다.
  • 확인 질문: Write Through가 항상 더 정합적인 선택이 아닌 이유는 무엇인가?
    • 답변: 쓰기 latency가 늘고 DB 쓰기와 캐시 쓰기 중 하나만 성공하는 실패 조합을 처리해야 하기 때문이다.
  • 확인 질문: @Cacheable을 쓸 때 별도 정책 문서가 필요한 이유는 무엇인가?
    • 답변: 애너테이션만으로 TTL, key, null 캐시, fallback, invalidation 책임이 충분히 드러나지 않기 때문이다.

참고 문서