Skip to content

Latest commit

 

History

History
359 lines (248 loc) · 15.6 KB

File metadata and controls

359 lines (248 loc) · 15.6 KB

캐시 전략 설계

🖼️ 배경

조회 시간이 오래 걸리거나, 요청 빈도가 높거나, 자주 변하지 않는 데이터를 대상으로 캐시를 적용하면 시스템 성능을 효율적으로 개선할 수 있다.

이번 전략에서는 전체 조회 기능 중 캐시가 효과적으로 작동할 수 있는 대상을 선별하고, 각 기능에 맞는 캐시 전략을 수립한다.
TTL(Time-To-Live), Eviction 정책, Warm-up 등 다양한 요소를 고려해 캐시의 적중률(Hit Rate)을 높이고 미스(Miss)를 줄이는 것이 목적이다.

단, 캐시는 성능 향상에 유리한 도구이지만, 데이터 정합성이 중요한 기능에서는 성능보다 정확성이 우선되어야 한다.
따라서 단순히 모든 조회에 일괄 적용하기보다, 기능 특성에 따라 정합성과 성능의 균형을 고려한 설계가 필요하다.

🗳️ 캐싱 대상 선정

⭐️ 인기 상품 조회

인기 상품 조회는 요청 빈도가 높고, 하루 동안 데이터가 변경되지 않는 특성상 캐시 적용에 매우 적합한 기능이다.
조회 수요는 높지만 변경 빈도는 낮기 때문에, 캐시를 통해 성능과 트래픽 처리 효율을 동시에 개선할 수 있다.

향후 기능이 확장되면, **상품 상세 조회(PDP)**와 같은 기능에도 캐시 적용을 고려할 수 있다.
또한 MSA 환경에서는 BFF(Backend for Frontend) 계층에서 멀티 캐시 레이어를 구성하여 API 응답 캐싱을 최적화할 수 있다.

✨ 캐싱 전략 - 인기 상품 조회

📖 캐시 읽기 전략

인기 상품 조회 기능Read Through 캐싱 패턴을 기반으로 동작한다.
서비스는 항상 Redis 캐시를 우선 조회하며, 캐시 미스 발생 시 DB에서 데이터를 조회한 뒤, 해당 결과를 캐시에 자동 저장한다.

img_2

  • (1) 사용자가 인기 상품 조회를 요청 한다.
  • (2) @Cacheable 어노테이션을 통해 Redis 캐시에서 데이터를 조회한다.
  • (3) 캐시 미스 발생 시 DB에서 데이터를 조회하고, 해당 결과가 Redis에 저장된다. (⚠️ 의도되지 않은 예외 상황)

단, (3)은 일반적인 캐시 미스라기보다, 배치 실패 등 시스템 장애로 인해 캐시가 사전 적재되지 않은 예외 상황일 수 있다.
예를 들어, 배치가 2회 이상 연속 실패하거나 TTL(예: 49시간) 만료 후 캐시가 삭제되면,
대량의 요청이 동시에 DB로 유입되어 캐시 스탬피드(Cache Stampede)가 발생할 수 있다.

@Service
@RequiredArgsConstructor
public class RankFacade {

    @Transactional(readOnly = true)
    @Cacheable(
        value = CacheType.CacheName.POPULAR_PRODUCT,
        key = "'top:' + #criteria.top + ':days:' + #criteria.days"
    )
    public RankResult.PopularProducts getPopularProducts(RankCriteria.PopularProducts criteria) {
        return getPopularProducts(criteria.getTop(), criteria.getDays()); // 📌 캐시 미스 시 DB 조회 및 캐시 저장
    }
}

✍️ 캐시 쓰기 전략

쓰기 전략은 Write Through 방식으로, 배치 기반 캐시 갱신을 사용한다. 사용자 요청 시점이 아닌, 매일 정해진 시각(00:05) 에 실행되는 스케줄러가 DB 저장과 동시에 캐시도 갱신한다.

img_1

  • (1) 매일 00:05, 스케줄러가 실행된다.
  • (2) 전일 주문/결제 데이터를 기반으로 인기 상품을 집계하여 DB에 저장한다.
  • (3), (4) 집계된 데이터를 기준으로 상품 정보를 포함한 인기 상품 데이터를 조회한다.
  • (5) 조회 결과를 @CachePut을 통해 Redis에 갱신한다.
@Service
@RequiredArgsConstructor
public class RankFacade {

    @Transactional(readOnly = true)
    @CachePut(
        value = CacheType.CacheName.POPULAR_PRODUCT,
        key = "'top:' + #criteria.top + ':days:' + #criteria.days"
    )
    public RankResult.PopularProducts updatePopularProducts(RankCriteria.PopularProducts criteria) {
        return getPopularProducts(criteria.getTop(), criteria.getDays()); // 📌 캐시 갱신용 DB 조회 및 저장
    }
}

⌛️ 캐시 TTL 전략

캐시 TTL(Time To Live)은 49시간으로 설정했다.

img

  • TTL을 24시간으로 설정할 경우, 매일 00:05에 실행되는 배치와 만료 시점이 겹쳐 캐시 미스 타이밍 리스크가 발생할 수 있다.
  • 이를 방지하고, 배치 실패 시 hotfix로 캐시를 수동 갱신할 수 있는 여유 시간을 확보하기 위해 TTL을 49시간으로 설정하였다.
  • 일 단위로 캐시를 주기적으로 갱신하므로, 별도의 eviction 정책은 적용하지 않는다.

💡 참고
매일 00:00 ~ 00:05 사이에는 캐시 갱신이 아직 반영되지 않았을 수 있다.
이 시간대에는 "최근 3일"이 아닌, 어제 ~ 4일 전 기준의 데이터가 조회될 수 있다.
이는 비즈니스적으로 허용 가능한 정합성 범위로 판단하여 수용한다.

public enum CacheType implements Cacheable {

    POPULAR_PRODUCT("인기 상품 캐싱") {
        @Override
        public String cacheName() {
            return CacheName.POPULAR_PRODUCT;
        }

        @Override
        public Duration ttl() {
            return Duration.ofHours(49); // 📌 TTL : 49시간
        }
    }
}

🧪 테스트를 통한 캐싱 검증

캐시 테스트 시 데이터 간섭을 방지하기 위해, Redis 캐시를 클렌징할 수 있는 전용 클래스를 작성하였다.
해당 클래스는 테스트 훅 @AfterEach를 통해 각 테스트 실행 이후 자동으로 캐시를 정리한다.

@Transactional
class RankFacadeCacheTest extends IntegrationTestSupport {

    @Autowired
    private RedisCacheCleaner redisCacheCleaner;

    @AfterEach
    void tearDown() {
        redisCacheCleaner.clean(); // 🧹 테스트 후 캐시 클렌징
    }
}

❌ 캐시 미스 시, 캐시 저장 테스트

캐시 미스 시, DB에서 조회한 데이터가 캐시에 저장되는지 확인한다.

@DisplayName("인기 상품을 캐싱 조회 한다.")
@Test
void getPopularProducts() {
    // given
    Optional<RankResult.PopularProducts> emptyCached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, RankResult.PopularProducts.class);

    // when
    rankFacade.getPopularProducts(RankCriteria.PopularProducts.ofTop5Days3());

    // then
    assertThat(emptyCached).isEmpty(); // 미리 캐시가 비어 있었는지 확인
    RankResult.PopularProducts cached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, RankResult.PopularProducts.class).orElseThrow();
    assertThat(cached.getProducts()).hasSize(3)
        .extracting("productId")
        .containsExactly(product3.getId(), product2.getId(), product1.getId());
}

💼 캐싱을 저장하는 테스트

@CachePut 어노테이션을 통해 캐시가 저장되는지 확인한다.

@DisplayName("인기 상품을 캐싱 한다.")
@Test
void updatePopularProductsForCache() {
    // given
    Optional<RankResult.PopularProducts> emptyCached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, RankResult.PopularProducts.class);

    // when
    rankFacade.updatePopularProducts(RankCriteria.PopularProducts.ofTop5Days3()); 

    // then
    assertThat(emptyCached).isEmpty(); 
    RankResult.PopularProducts cached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, RankResult.PopularProducts.class).orElseThrow();
    assertThat(cached.getProducts()).hasSize(3)
        .extracting("productId")
        .containsExactly(product3.getId(), product2.getId(), product1.getId());
}

🚀 캐싱 갱신 테스트

기존 캐시가 존재하는 상태에서 @CachePut을 통해 값이 덮어써지는지 검증한다.

@DisplayName("인기 상품을 캐시 갱신 한다.")
@Test
void updatePopularProductsForRefresh() {
    // given
    redisCacheTemplate.put(CacheType.POPULAR_PRODUCT, cacheKey, "test");
    Optional<String> existCached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, String.class);

    // when
    rankFacade.updatePopularProducts(RankCriteria.PopularProducts.ofTop5Days3());

    // then
    assertThat(existCached).isPresent();
    assertThat(existCached.get()).isEqualTo("test");

    // 캐시가 갱신되었는지 확인
    RankResult.PopularProducts cached = redisCacheTemplate.get(CacheType.POPULAR_PRODUCT, cacheKey, RankResult.PopularProducts.class).orElseThrow();
    assertThat(cached.getProducts()).hasSize(3)
        .extracting("productId")
        .containsExactly(product3.getId(), product2.getId(), product1.getId());
}

✅ 테스트 결과

모든 테스트가 성공적으로 통과하며, 캐시 조회/저장/갱신이 의도대로 동작함을 검증하였다.

img_3

🏎️ 캐시 성능 비교

캐시 성능을 검증하기 위해 K6(성능 및 부하 테스트), InfluxDB(메트릭 수집), Grafana(시각화)를 활용하여 성능 테스트를 수행하였다.
주요 목적은 캐시 적용 전후의 응답 속도 및 처리량 차이를 측정하여, 캐시 도입의 효과를 검증하는 것이다.

본 테스트는 로컬 환경에서 수행되었으며, 향후에는 운영 환경과 유사한 조건에서의 검증이 추가로 필요하다.

🗃️️ 테스트 케이스

총 2가지 케이스를 기준으로 테스트를 수행하였다.

☝️ [CASE 1] 캐싱 미적용 - DB 단순 조회

@Cacheable 어노테이션을 제거한 상태에서, 단순히 DB를 직접 조회한다.

@Transactional(readOnly = true)
// @Cacheable(value = CacheType.CacheName.POPULAR_PRODUCT, key = "'top:' + #criteria.top + ':days:' + #criteria.days")
public RankResult.PopularProducts getPopularProducts(RankCriteria.PopularProducts criteria) {
    return getPopularProducts(criteria.getTop(), criteria.getDays());
}

✌️ [CASE 2] 캐싱 적용 - Redis 기반 캐시 조회

@Cacheable 어노테이션을 활성화하여, Redis에 저장된 데이터를 조회한다.

@Transactional(readOnly = true)
@Cacheable(value = CacheType.CacheName.POPULAR_PRODUCT, key = "'top:' + #criteria.top + ':days:' + #criteria.days")
public RankResult.PopularProducts getPopularProducts(RankCriteria.PopularProducts criteria) {
    return getPopularProducts(criteria.getTop(), criteria.getDays());
}

📜 테스트 시나리오

가상의 사용자(Virtual User)가 반복적으로 인기 상품 조회 API를 호출하는 테스트 시나리오를 구성하였다. 총 테스트 시간은 70초이며, 다음과 같이 단계적으로 부하를 증가 및 감소시키며 성능을 측정하였다.

  • 0초~10초: 0 → 25명으로 증가
  • 10초~20초: 25 → 50명으로 증가
  • 20초~30초: 50 → 100명으로 증가
  • 30초~40초: 100명 유지
  • 40초~50초: 100 → 50명으로 감소
  • 50초~60초: 50 → 25명으로 감소
  • 60초~70초: 25 → 0명으로 감소

🧪 테스트 결과

☝️ DB 단순 조회 (캐싱 미적용)

img_5

  • 총 요청 수: 3,371건
  • 평균 응답 시간: 54.04ms
  • p95 응답 시간: 169.19ms
  • 오류율: 0%

응답 시간은 30~150ms에 분포하였으며, 최대 321ms까지 지연이 발생하였다.

✌️ 캐시 조회 (@Cacheable 적용)

img_4

  • 총 요청 수: 3,541건
  • 평균 응답 시간: 2.1ms
  • p95 응답 시간: 5.4ms
  • 오류율: 0%

대부분의 요청이 0~5ms 이내에 응답되었으며, Redis 캐시 히트가 효과적으로 작동함을 확인하였다.

📈 응답 시간 비교

구분 DB 조회 캐시 조회 성능 개선
평균 응답시간 54.04 ms 2.1 ms 25.7배
중앙값 (Median) 34.41 ms 1.28 ms 26.9배
최소 응답시간 17.74 ms 410 µs 43.3배
최대 응답시간 321.52 ms 94.7 ms 3.4배
90% 응답시간 (p90) 113.4 ms 3.19 ms 35.5배
95% 응답시간 (p95) 169.19 ms 5.4 ms 31.3배

p90/p95는 각각 상위 90%, 95%의 요청이 해당 시간 이하로 응답되었음을 의미한다.

📊 처리량 비교

지표 DB 조회 캐시 조회 성능 개선
총 요청 처리 3,371건 3,541건 5.0% 증가
초당 요청 처리 (RPS) 47.73 / 초 50.36 / 초 5.5% 증가

✅ 결과 요약

  • 응답 시간: 평균 기준 약 25.7배 개선되었고, p95 기준으로도 31배 이상 개선되었다.
  • 처리량: Redis 캐시 적용 후 약 5% 더 많은 요청을 처리하여 시스템 처리 효율이 향상되었음을 확인할 수 있다.

🚧 한계

1️⃣ @Cacheable 어노테이션의 한계

현재 사용 중인 @Cacheable 어노테이션은 Redis 장애 발생 시 자동으로 DB로 우회(fallback) 하는 기능이 없다.
이로 인해 Redis에 문제가 발생하면, 전체 서비스의 응답 지연 또는 장애로 확산될 수 있는 구조적 리스크가 존재한다.

이를 보완하기 위해 resilience4j 등의 라이브러리를 활용해 Circuit Breaker 패턴을 적용하면,
Redis 접근 실패 시 DB로 우회하거나 즉시 실패 응답을 반환하는 등 유연한 장애 대응이 가능하다.

또한 Redis의 역할을 분산 락용 Redis와 캐시용 Redis로 물리적으로 분리하면, 각 역할의 부하를 독립적으로 관리할 수 있다.

2️⃣ Redis 부하로 인한 병목 현상

요청량이 증가하거나 TTL이 짧은 경우, Redis에 대한 네트워크 I/O가 집중되면서 병목 현상이 발생할 수 있다.
특히 캐시 미스가 자주 발생하면, Redis가 반복적으로 DB 접근을 유도하게 되어 오히려 전체 시스템의 성능이 저하될 수 있다.

이를 해결하기 위해 Caffeine과 같은 로컬 캐시를 Redis 앞단에 배치한 Multi Cache Layer 구조를 도입할 수 있다.
자주 반복되는 요청은 로컬 캐시에서 응답하도록 하여, Redis 부하를 분산하고 전반적인 처리 성능을 극대화할 수 있다.

✅ 결론

이번 전략을 통해 Redis 기반 캐시 도입이 인기 상품 조회 기능의 응답 속도 및 처리량을 크게 개선함을 확인할 수 있었다.
이처럼 캐시는 시스템 성능을 높이는 핵심 도구이자, 서비스 운영 효율을 극대화하는 필수 요소로 자리 잡았다.

단, 캐시 적용 시에는 데이터 정합성과 캐시 갱신 주기, TTL 관리, 스탬피드 대응 등 다양한 요소를 함께 고려해야 하며,
서비스 특성과 비즈니스 요구에 맞는 정교한 캐싱 전략이 필요하다.

캐시 적용 이후에는 정기적으로 캐시 적중률(Hit Rate)과 미스(Miss) 비율을 모니터링하여,
캐시 성능을 지속적으로 개선할 수 있는 방안을 마련해야 한다.

또한, 서비스 규모가 커짐에 따라 Redis에 대한 의존성과 부하가 증가하는 한계점이 드러날 수 있다.
따라서 Redis 부하를 분산하기 위해 Warm-up 전략으로 캐시를 미리 적재하거나,
로컬 캐시를 활용한 Multi Cache Layer 구조를 도입해 성능을 극대화할 수 있다.