상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)

2026. 7. 23. 20:35·💭Retrospective

안녕하세요! 이번 글에서는 서비스 내에서 가장 많은 조회 트래픽을 받는 '상품 상세 조회 API'의 성능을 개선하고, Redis 캐시 도입 과정에서 겪었던 데이터 정합성 이슈와 해결 과정을 기록해보려 합니다. 단순히 "Redis를 붙였더니 몇 배 빨라졌다"에서 그치지 않고, 왜 이 전략을 선택했는지, 트랜잭션과의 연계에서 발생할 수 있는 엣지 케이스를 어떻게 방지했는지, 그리고 k6를 통해 지표로 어떻게 검증했는지 상세히 나누어 보겠습니다.

1. 개요 및 배경 (Problem Statement)

상품 상세 조회 API의 특성

예시 사례: 급상승 중인 리센느의 인형 굿즈

서비스 내의 다양한 API 중 상품 상세 조회 API는 독특한 특징을 가집니다.

  1. 압도적인 읽기 요청(Read Demand)
    - 사용자가 메인 페이지, 검색 페이지, 장바구니 등을 거치며 가장 자주 호출하는 대표적인 조회 중심 API입니다.
  2. 낮은 수정 빈도(Low Write Demand)
    - 반면 상품명, 가격, 기본 설명 등의 데이터는 조회 빈도에 비해 변경되는 일이 매우 드뭅니다.

문제 상황: 모든 요청이 DB로 몰린다!

기존 구조에서는 캐시 레이어가 없어 모든 상세 조회 요청이 데이터베이스(RDB)까지 직접 전달되었습니다.

개선 전 아키텍처

당장 현재 트래픽에서는 DB 조회만으로도 응답 속도가 크게 나쁘지 않을 수 있습니다. 하지만 문제는 트래픽이 폭증하는 이벤트 상황이나 동시 접속자가 늘어나는 시점입니다. DB가 조회 트래픽을 감당하느라 Connection을 점유하고 CPU/메모리 부하가 높아지면, 동일한 DB를 공유하는 상품 수정, 주문, 결제 등 핵심 트랜잭션 기능까지 응답 지연이 전파(Cascading Failure)되는 치명적인 위험이 있었습니다.

해결해야 할 과제

  1. 반복되는 조회 트래픽을 DB 이전 레이어에서 차단하여 DB 읽기 부하 감소
  2. 상품 수정 시 캐시와 DB 간 데이터 불일치(Stale Data) 문제 완벽 해결

2. 기술적 의사결정과 캐시 전략 (Technical Decision)

조회 성능과 데이터 정합성이라는 두 마리 토끼를 잡기 위해 몇 가지 핵심 기술적 의사결정을 내렸습니다.

2.1 캐시 패턴: Cache-Aside (Lazy Loading) 전략

캐시 패턴에는 Read-Through, Write-Through, Write-Behind 등 다양한 방식이 있지만, Cache-Aside 패턴을 선택했습니다.

  • 예상된 동작 방식
    1. Application은 먼저 Redis(캐시)에서 데이터를 조회합니다.
    2. Cache Hit: 캐시에 데이터가 존재하면 즉시 반환합니다.
    3. Cache Miss: 캐시에 데이터가 없으면 DB에서 데이터를 가져와 Redis에 저장한 뒤 결과를 반환합니다.
  • 선택한 이유
    • 안전성(Fall-back)
      - Redis 장애가 발생해도 서비스가 완전히 마비되지 않고,
      DB 조회를 통해 비즈니스 로직을 유지할 수 있습니다.
    • 메모리 효율성
      - 모든 상품을 미리 적재(Warm-up)하지 않고,
      실제로 요청이 들어오는 '주요 상품'만 캐시에 적재되므로 Redis 메모리를 효율적으로 사용합니다.

2.2 직렬화 방식: GenericJackson2JsonRedisSerializer (JSON)

Redis에 Java 객체를 저장할 때 직렬화 방식 선택이 중요합니다.

  • JDK 기본 직렬화 (JdkSerializationRedisSerializer)
    • 설정은 간편하지만 데이터가 Binary 형태로 저장되어 Redis CLI에서 사람이 읽을 수 없습니다.
    • 클래스의 `serialVersionUID`나 내부 구조가 변경되면 `DeserializationException`이 터지는 단점이 있습니다.
  • JSON 직렬화 (GenericJackson2JsonRedisSerializer)
    • 데이터를 사람이 읽을 수 있는 JSON 형태로 저장합니다.
    • 장점: Redis CLI로 직접 데이터 조회가 가능하여 운영 중 장애 분석과 디버깅이 매우 용이합니다.

2.3 데이터 정합성 및 캐시 무효화 (Cache Eviction)

캐시 도입 시 가장 위험한 순간은 "DB 데이터는 바뀌었는데, 캐시에는 옛날 데이터가 남아있을 때(Stale Data)"입니다. 예를 들어 가격이 10,000원에서 12,000원으로 변경되었는데 캐시에서 10,000원이 조회되면 심각한 비즈니스 오류가 발생합니다.

저희는 이를 해결하기 위해 '상품 수정 시 해당 상품 ID의 캐시를 즉시 삭제(Evict)'하는 방식을 채택했습니다.

💡 왜 캐시를 직접 수정(Update)하지 않고 삭제(Evict)하나요?
수정 API 내에서 캐시 데이터까지 직접 업데이트 하려면 수정 로직이 캐시의 데이터 구조까지 깊게 알아야 해서 결합도가 높아집니다. 또한 DB 수정은 성공했는데 캐시 업데이트만 실패했을 때 정합성을 맞추기 매우 까다로워집니다. 삭제(Evict) 방식을 사용하면 DB를 단일 진실 공급원(Single Source of Truth)으로 유지하면서, 다음 조회 시점에 최신 데이터가 자연스럽게 캐싱되므로 훨씬 안전합니다.

2.4 트랜잭션과 캐시 제거 시점 동기화 (transactionAware)

이 부분이 구현 과정에서 가장 섬세하게 다뤄야 했던 대목입니다!

Spring의 `@CacheEvict`를 메서드에 선언하면, 기본적으로 메서드가 실행되는 시점에 캐시 삭제 명령을 Redis로 보냅니다.
하지만 메서드에 `@Transactional`이 걸려있다면 어떻게 될까요?

[문제 상황 예시]
1. 상품 수정 메서드 호출 (`@Transactional` 시작)
2. DB 데이터 UPDATE 쿼리 실행
3. `@CacheEvict` 작동 ──> Redis 캐시 삭제 완료
4. 예외 발생으로 인한 DB Transaction ROLLBACK!
👉 결과: DB 데이터는 수정되지 않았는데, 캐시만 억울하게 삭제됨 (불필요한 Cache Miss 발생)

더 복잡한 캐시 갱신 전략에서는 DB 커밋 전 캐시가 지워진 틈을 타 다른 조회 요청이 들어와 롤백 전의 예전 데이터를 다시 캐싱해버리는 문제(Race Condition)가 터질 수도 있습니다. 이 문제를 막기 위해 `RedisCacheManager` 설정에 `setTransactionAware(true)`를 활성화했습니다.

이 설정을 통해 DB 트랜잭션이 안전하게 `COMMIT` 된 것을 확인한 후 Redis에 삭제 명령이 내려가도록 구현했습니다.

3. 주요 구현 코드 (Implementation)

RedisCacheConfig.java

@Configuration
@EnableCaching
@Profile("cache") // 테스트 및 비교 환경 분리를 위해 Profile 제어
public class RedisCacheConfig {

    @Bean
    public CacheManager productCacheManager(RedisConnectionFactory connectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule()); // LocalDate 등 날짜 타입 지원
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance, 
            ObjectMapper.DefaultTyping.NON_FINAL
        );

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofHours(1)) // 1시간 TTL 설정 (보조 안전장치)
            .disableCachingNullValues()  // Null 값 캐싱 방지
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .transactionAware() // DB 트랜잭션 커밋 후 캐시 작업 실행
            .build();
    }
}

 

`disableCachingNullValues()`를 적용한 이유

존재하지 않는 상품 ID 조회 결과(Null)까지 캐싱해 버리면, 추후 해당 ID로 신규 상품이 생겼을 때 캐시 만료 전까지 상품이 조회되지 않는 side-effect를 방지하기 위함입니다.

 

 

4. k6를 활용한 성능 검증 (Performance Testing)

개선 효과를 검증하기 위해 OpenSource 부하 테스트 도구인 k6를 도입했습니다. 단순 호출이 아닌, 실제 운영 환경의 트래픽 조회 편향(파레토 법칙)을 모사하고 정확한 비교 측정을 위한 디테일한 옵션들을 스크립트에 반영했습니다.

4.1  k6 부하 테스트 스크립트의 주요 설계 포인트

  1. 현실적인 상품 조회 트래픽 편향 (80/15/5 파레토 법칙 적용)
    • 실제 이커머스 환경에서는 특정 인기 상품에 조회가 몰립니다.
    • `selectProductId()` 함수를 통해 상위 20개 인기 상품이 전체 요청의 80%를 차지하고,
      일반 상품(15%), 롱테일 상품(5%)이 나머지를 구성하도록 분포를 모델링했습니다.
  2. `setup()` 함수를 활용한 Cold / Warm Cache 조건 통제
    • 환경 변수(`WARM_CACHE=true`)에 따라 본 테스트 시작 전 `setup()` 단계에서 `http.batch()`를 통해 인기 상품 1~20번의 캐시를 미리 적재합니다.
    • `setup()` 단계의 요청은 본 테스트의 메트릭 집계(`http_req_duration`)에 영향을 주지 않으므로,
      오직 순수한 Warm Cache 상태의 응답 속도만 깔끔하게 측정할 수 있습니다.
  3. `discardResponseBodies: true` 옵션을 통한 k6 리소스 오버헤드 방지
    • 본 테스트는 성능(Latency/RPS) 측정 목적이므로 응답 본문을 메모리에 보관하지 않습니다. 이를 통해 k6 실행 머신의 CPU/RAM 사용량을 줄여 측정 도구 때문에 발생할 수 있는 데이터 왜곡을 방지했습니다.
  4. constant-arrival-rate 실행기 및 상세 메트릭(Counter/Rate) 수집
    • Virtual User(VU)의 수와 무관하게 초당 200 RPS의 일정 부하를 지속적으로 주입하도록 설정했습니다.
    • 4xx, 5xx 에러 및 커스텀 검증 비율(product_detail_valid_responses)을 추적하는 커스텀 메트릭을 추가하여 테스트의 정확도를 높였습니다.

k6 테스트 스크립트(script.js)

import http from 'k6/http';
import { check } from 'k6';
import { Counter, Rate } from 'k6/metrics';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';

const RATE = Number(__ENV.RATE || 200);
const DURATION = __ENV.DURATION || '3m';
const PRE_ALLOCATED_VUS = Number(__ENV.PRE_ALLOCATED_VUS || 50);
const MAX_VUS = Number(__ENV.MAX_VUS || 300);

const WARM_CACHE = (__ENV.WARM_CACHE || 'false').toLowerCase() === 'true';

// 커스텀 지표 정의
const requestFailures = new Counter('product_detail_failures');
const clientErrors = new Counter('product_detail_4xx_errors');
const serverErrors = new Counter('product_detail_5xx_errors');
const validResponses = new Rate('product_detail_valid_responses');

export const options = {
  /*
   * k6 자체의 메모리/CPU 사용량을 줄여 서버 성능 측정 왜곡을 방지
   */
  discardResponseBodies: true,

  scenarios: {
    product_detail_read: {
      /*
       * 캐시 적용 전후를 동일한 요청량(200 RPS)으로 비교하기 위한 constant-arrival-rate
       */
      executor: 'constant-arrival-rate',
      rate: RATE,
      timeUnit: '1s',
      duration: DURATION,
      preAllocatedVUs: PRE_ALLOCATED_VUS,
      maxVUs: MAX_VUS,
      gracefulStop: '10s',
    },
  },

  thresholds: {
    // 전체 HTTP 실패율 0.1% 미만
    http_req_failed: ['rate<0.001'],

    // 엔드포인트 별 Latency 임계치 (p95 < 200ms, p99 < 500ms)
    'http_req_duration{endpoint:product-detail}': [
      'p(95)<200',
      'p(99)<500',
    ],

    // 상태 코드 검증 성공률 99.9% 이상
    product_detail_valid_responses: ['rate>0.999'],

    // 실패 요청 및 5xx 서버 에러 0건
    product_detail_failures: ['count==0'],
    product_detail_5xx_errors: ['count==0'],
  },

  summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
};

function randomInteger(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

/**
 * 실제 서비스의 상품 조회 편향을 단순화한 분포
 * 80%: 인기 상품 (1~20)
 * 15%: 일반 상품 (21~1000)
 *  5%: 롱테일 상품 (1001~10000)
 */
function selectProductId() {
  const probability = Math.random();

  if (probability < 0.80) {
    return randomInteger(1, 20);
  }
  if (probability < 0.95) {
    return randomInteger(21, 1000);
  }
  return randomInteger(1001, 10000);
}

/**
 * WARM_CACHE=true 설정 시 실행되는 사전 워밍업
 * setup() 실행 결과는 메트릭 집계(http_req_duration)에서 제외됨
 */
export function setup() {
  if (!WARM_CACHE) {
    console.log('Warm-up disabled: Cold Cache 또는 No Cache 테스트 진행');
    return;
  }

  console.log('Warm-up enabled: 인기 상품 1~20 캐시 적재 시작');

  const requests = [];
  for (let productId = 1; productId <= 20; productId += 1) {
    requests.push({
      method: 'GET',
      url: `${BASE_URL}/api/v1/products/${productId}`,
      params: {
        tags: { endpoint: 'cache-warmup', name: 'Cache warm-up' },
        timeout: '5s',
      },
    });
  }

  const responses = http.batch(requests);
  const warmupSuccess = responses.every((response) => response.status === 200);

  if (!warmupSuccess) {
    const failures = responses
      .map((response, index) => ({
        productId: index + 1,
        status: response.status,
      }))
      .filter((result) => result.status !== 200);

    throw new Error(`캐시 워밍업 실패: ${JSON.stringify(failures)}`);
  }

  console.log('Warm-up completed: 인기 상품 1~20 캐시 적재 완료');
}

export default function () {
  const productId = selectProductId();

  const response = http.get(`${BASE_URL}/api/v1/products/${productId}`, {
    tags: {
      name: 'GET /api/v1/products/{productId}',
      endpoint: 'product-detail',
    },
    timeout: '5s',
  });

  const success = check(response, {
    '상품 상세 응답 상태가 200이다': (res) => res.status === 200,
  });

  validResponses.add(success);

  if (success) {
    return;
  }

  // 예외 및 에러 카운팅
  requestFailures.add(1);

  if (response.status >= 400 && response.status < 500) {
    clientErrors.add(1);
  }

  if (response.status >= 500) {
    serverErrors.add(1);
  }
}

4.2 테스트 결과 비교: No Cache vs Warm Cache

캐시 미적용 (No Cache)
Warmming Cache (캐시 적용 후 최초 실행)
Warm Cache (적용 후)

 

위 스크립트를 통해 캐시 미적용(No Cache) 상태와 사전 워밍업이 완료된 Warm Cache(`WARM_CACHE=true`) 상태에서 각각 3분간 200 RPS 부하를 가하여 얻은 지표 비교입니다.

지표 (Metrics) 캐시 미적용 (No Cache) Warm Cache (적용 후) 개선율
평균 응답시간 (Avg Latency) 5.19 ms 1.71 ms 약 67% 감소 ⚡
중앙값 (Median Latency) 4.26 ms 1.37 ms 약 68% 감소 ⚡
p95 Latency 6.75 ms 5.32 ms 약 21% 감소
p99 Latency 11.33 ms 6.82 ms 약 40% 감소 🎯
요청 누락 (Dropped Iterations) 27 건 0 건 100% 해so (완성도 향상) 🔥
성공률 / 에러건수 100% (0건) 100% (0건) 기준 충족

결과 분석 및 인사이트

  1. 상위 80% 인기 상품의 In-Memory 반환 효과 (Avg 67% 감소)
    • `selectProductId()`로 모델링한 인기 상품(1~20번)들이 `setup()` 단계에서 Redis에 완벽하게 Warm-up되어, 트래픽의 80% 이상이 DB I/O 없이 1.37ms(중앙값) 내외의 초저지연으로 처리되었습니다.
  2. p99 지연 시간 및 Dropped Iterations 100% 해소
    • No Cache 상태에서는 초당 200건의 요청이 지속적으로 DB Connection을 점유하면서 27건의 Dropped Iterations(k6가 설정된 200 RPS 비율을 맞추지 못하고 요청을 놓치는 현상)가 발생했습니다.
    • 반면 Warm Cache 환경에서는 Redis가 읽기 부하를 흡수해 줌에 따라 단 한 건의 Dropped Iteration 없이 200 RPS 부하를 완벽히 소화했으며, 상위 1% Latency(p99)도 11.33ms에서 6.82ms로 크게 안정화되었습니다.
  3. 재현성 검증 (Repeatability)
    • Warm Cache 스크립트를 동일 조건으로 3회 반복 실행하였으며, 평균 응답 시간 1.63ms ~ 1.76ms, p99 6.62ms ~ 6.95ms, Dropped Iterations 0건으로 지표가 일관되게 재현되어 시스템 차원의 구조적 안정성이 입증되었습니다.

5. 마치며 (Takeaways)

이번 Redis 캐시 도입 과정을 통해 배운 점은 다음과 같습니다.

  • 캐시는 속도 향상만의 도구가 아니다: 단순히 API 응답 속도를 빠르게 만드는 것을 넘어, DB 부하를 차단하여 시스템 전체의 안정성을 끌어올리는 방화벽 역할을 수행함을 체감했습니다.
  • 트랜잭션과의 연계 고려: Framework가 제공하는 @CacheEvict를 무작정 사용하기보다, DB 커밋 시점과 캐시 삭제 시점 간의 시차/롤백 가능성을 고려해 transactionAware 설정을 챙기는 철저함이 중요하다는 것을 배웠습니다.
  • 수치 기반의 검증: "좋아졌을 것이다"라는 추측 대신 k6 부하 테스트를 통한 객관적 지표(Latency, Dropped Iterations)로 성과를 증명하는 포터블한 아키텍처 검증 능력을 기를 수 있었습니다.

'💭Retrospective' 카테고리의 다른 글

동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기  (0) 2026.07.24
동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?  (0) 2026.07.24
클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까?  (0) 2026.05.24
GitHub Copilot Dev Days Seoul: Microsoft korea에서 AI 코딩 어시스턴트와 함께하는 실전 개발 워크샵 후기  (0) 2026.04.26
이것이 스프링 AI다: Claude는 써봤는데, Spring AI로 어떻게 서비스로 만들까?  (0) 2026.04.26
'💭Retrospective' 카테고리의 다른 글
  • 동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기
  • 동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?
  • 클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까?
  • GitHub Copilot Dev Days Seoul: Microsoft korea에서 AI 코딩 어시스턴트와 함께하는 실전 개발 워크샵 후기
limdaeil
limdaeil
limdaeil 님의 블로그 입니다.
  • limdaeil
    limdaeil
    limdaeil
  • 전체
    오늘
    어제
    • 분류 전체보기 (82) N
      • 💭Retrospective (21)
      • 🥕FrontEnd (0)
      • 🐬MySQL (1)
      • 🐍Python (5)
      • 🍃SpringBoot (33) N
      • ☕Java (1)
      • ♾️Devops (1)
      • 🌎Network (2)
      • 📚Read & 👨‍🏫Course (10)
      • Programmers (1)
      • 🧪Test (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    나는리뷰어다
    Python
    jwt
    Spring
    책
    optimistic rock
    서평단
    DI
    IoC
    한빛미디어
    redis
    spring boot
    Concurrency
    Mockito
    한빛아카데미
    회고
    distributed lock
    junit
    gradle
    MySQL
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
limdaeil
상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)
상단으로

티스토리툴바