유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Devlog/SpringBoot

상품 목록 조회 병목 개선기 - 인덱스 최적화와 부분 캐싱

Madirony 2026. 3. 13. 12:53
반응형

Intro

tl;dr 좋아요 수 기반 정렬 시 필연적으로 발생하는 DB 병목을 1단계 비정규화와 2단계 복합 인덱스로 해결하여 100만 건 기준 약 141배, 1,000만 건 스트레스 테스트에서 약 2,717배의 조회 성능 개선을 달성했습니다. 또한, Redis 부분 캐싱(Partial Caching)과 이벤트 성격별 무효화 전략을 도입해 조회 API의 안정성을 확보한 과정을 정리했습니다.

 

커머스 서비스에서 상품 목록 조회는 전체 트래픽의 대다수를 차지하는 Read-heavy 워크로드입니다. 현재 진행 중인 프로젝트에는 '특정 브랜드의 상품 목록을 좋아요 순으로 정렬'하여 반환하는 API가 존재합니다.

 

만약 데이터 정규화 원칙을 엄격하게 고수하여 정렬 기능을 구현한다면, products 테이블과 likes 테이블을 조인(LEFT JOIN)한 뒤 GROUP BY와 COUNT를 사용해 좋아요 수를 집계하는 구조가 만들어집니다.

-- 정규화 상태를 가정한 좋아요 순 정렬 쿼리
SELECT p.id, p.name, p.base_price, COUNT(l.id) AS like_count
FROM products p
LEFT JOIN likes l ON l.product_id = p.id
WHERE p.brand_id = 1 AND p.deleted = false
GROUP BY p.id, p.name, p.base_price
ORDER BY like_count DESC
LIMIT 20 OFFSET 0;

하지만 데이터 규모가 커질수록 이러한 정규화 구조는 성능 저하를 유발합니다. 10만 건의 상품 데이터를 기준으로 쿼리 실행 계획(EXPLAIN)을 분석해 보면 병목 지점을 확인할 수 있습니다.

  • 대량의 JOIN 연산: 좋아요 데이터가 쌓일수록 조인 후 스캔해야 하는 행(row)의 수가 선형적으로 증가합니다.
  • 인덱스 활용 불가 (filesort): 동적으로 집계된 COUNT 결과값은 테이블 인덱스를 탈 수 없기 때문에, 매 요청마다 Using temporary; Using filesort가 발생하여 별도의 정렬 연산을 수행해야 합니다.

 

이러한 구조에서는 단일 쿼리 실행에 수백 밀리초(ms)가 소요되며, 트래픽이 집중될 경우 DB 부하가 우려됩니다. 이를 사전에 차단하고 대용량 트래픽에서도 견딜 수 있는 API를 만들기 위해, DB 구조 최적화와 Redis 캐시 도입을 단계적으로 진행했습니다.

 

redis

 

 


본론

1. 비정규화를 통한 JOIN 병목 해소

가장 먼저 마주한 문제는 대량의 LEFT JOIN 연산이었습니다. 상품 10만 건에 평균 50개의 좋아요가 존재한다고 가정할 때, 매 조회마다 500만 행의 데이터를 조인하고 집계해야 합니다.

 

이러한 읽기(Read) 병목을 해소하기 위해 Product 테이블에 like_count 컬럼을 직접 관리하는 비정규화(Denormalization) 구조로 TO-BE 모델을 설계했습니다. DB가 자동 갱신하는 Materialized View를 활용하는 대안도 검토했으나, MySQL 환경에서의 제약과 수동 리프레시(REFRESH) 비용을 고려했을 때 읽기 비율이 압도적으로 높은 현재 커머스 도메인에서는 컬럼 비정규화가 더 실용적인 해결책이라고 판단했습니다.

 

비정규화 도입 시 가장 우려되는 지점은 좋아요 등록/취소 시 발생하는 동시성 문제(Lost Update)입니다. 이를 방지하기 위해 애플리케이션 레벨에서 엔티티를 조회하고 상태를 변경하는 대신, DB 레벨의 벌크 연산을 사용하여 원자성(Atomicity)을 보장했습니다.

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id AND p.deleted = false")
int increaseLikeCount(@Param("id") Long id);

이러한 결정으로 도메인 객체가 자신의 상태를 직접 관리해야 한다는 객체지향적 순수성은 일부 양보하게 되었습니다. 하지만 동시성이 높은 환경에서는 데이터의 정합성을 지키고 DB 락(Lock) 경합을 최소화하는 것이 더 우선되어야 할 가치라고 판단했습니다.

 


2. 복합 인덱스 설계를 통한 filesort 제거

비정규화를 통해 JOIN 연산은 제거했지만, 성능 문제는 완전히 해결되지 않았습니다. ORDER BY like_count DESC를 수행할 때 테이블 인덱스를 타지 못해 여전히 테이블 풀스캔(Full Scan)과 별도의 정렬 작업(filesort)이 발생했기 때문입니다.

EXPLAIN
SELECT *
FROM products
WHERE brand_id = 1
  AND deleted = 0
ORDER BY like_count DESC
LIMIT 20 OFFSET 0;

Using filesort

 

그래서 정렬 병목을 해소하기 위해 '브랜드별 좋아요 순 조회' 유스케이스를 타겟으로 복합 인덱스를 설계했습니다.

CREATE INDEX idx_products_brand_deleted_likes
ON products (brand_id, deleted, like_count DESC);

등치 조건(brand_id, deleted)으로 스캔 범위를 먼저 좁힌 뒤, 이미 정렬된 리프 노드(like_count DESC)를 순차적으로 읽어 오기 때문에 filesort를 구조적으로 제거할 수 있습니다.

 

10만건일 때 인덱스 적용 후 EXPLAIN (type: ref, filesort 제거됨)

 


성능 비교: 10만 건 vs 100만 건 스케일업

추가로 Docker 환경에 100만 건의 데이터를 적재하여 인덱스 유무에 따른 성능을 측정했습니다.

 

성능 비교표

 

데이터 10배 증가 시, 풀스캔은 8.8배 성능 저하

인덱스가 없을 때(AS-IS), 10만 건에서 22ms였던 응답 시간이 100만 건에서 201ms로 약 8.8배 증가했습니다. 데이터 규모에 따라 성능이 선형적으로 나빠지는 O(N)의 시간 복잡도를 확인할 수 있습니다.

 

인덱스 활용 시, 데이터 10배 증가에도 성능은 7.1배 저하에 그침

반면 복합 인덱스를 적용한 경우, 데이터가 10배 늘어났음에도 응답 시간은 0.2ms에서 1.43ms로 약 7.15배 증가하는 데 그쳤습니다. B-Tree 기반 인덱스 탐색의 시간 복잡도가 O(log N)이기 때문이며, 데이터가 방대해질수록 인덱스의 가치가 극대화되는 것을 확인할 수 있습니다.

 


1,000만 건 스트레스 테스트

100만 건에서 멈추지 않고, INSERT INTO ... SELECT 자기 복제 기법으로 Docker MySQL에 1,000만 건까지 스케일업하여 한계를 테스트했습니다.

 

규모 풀스캔 (인덱스 없음) 인덱스 적용 개선율
10만 22.78ms 0.20ms 114배
100만 201.17ms 1.43ms 141배
1,000만 1,440ms 0.53ms 2,717배

 

풀스캔은 예상대로 O(N) 선형 증가를 보입니다(22ms → 201ms → 1,440ms). 반면 인덱스 쿼리는 데이터가 100배 증가해도 sub-ms 범위에 머뭅니다(0.20ms → 1.43ms → 0.53ms).

 

1,000만 건의 인덱스 조회(0.53ms)가 100만 건(1.43ms)보다 오히려 빠르게 측정된 것은 오류가 아닙니다. 1,000만 건 적재 직후 인덱스 페이지가 MySQL InnoDB Buffer Pool(메모리)에 적재된 상태(Hot Cache)에서 쿼리가 실행되었기 때문입니다. 메모리 캐싱과 LIMIT 20의 조기 종료가 결합되면, 데이터가 1,000만 건이어도 응답 시간이 1ms 언더로 떨어지는 B-Tree의 효율성을 확인할 수 있었습니다.

 

EXPLAIN의 rows(426,078)는 매칭 행의 추정치이지만, MySQL 옵티마이저는 인덱스가 이미 like_count DESC로 정렬되어 있으므로 첫 20건만 읽고 즉시 반환합니다. 실제 스캔 행 수는 데이터 규모와 무관하게 20건이므로, 인덱스 + LIMIT 쿼리의 실행 비용은 사실상 O(1)에 수렴합니다.

 

인덱스 최적화로 인한 이익

1,000만 건에서 인덱스가 없다면 동시 접속자 100명의 캐시 미스가 144초(100 × 1,440ms)의 DB CPU 시간을 소비합니다. 연쇄 타임아웃과 커넥션 풀 고갈이 불가피합니다. 반면 인덱스가 있다면 53ms(100 × 0.53ms)로 처리되며, DB는 캐시가 복구되는 짧은 구간을 여유롭게 버팁니다. 인덱스 최적화는 Redis 장애나 캐시 만료 시에도 DB가 자체적으로 방어할 수 있는 구조적 안정성을 확보합니다.

 


3. Redis Cache-Aside 패턴 도입

인덱스 최적화로 단건 쿼리의 성능은 확보했지만, 인기 브랜드 페이지에 트래픽이 집중되면 동일한 쿼리가 초당 수백 회 반복 실행됩니다. 아무리 빠른 쿼리라도 반복되면 DB 커넥션 풀이 고갈될 수 있기 때문에, 반복 조회를 흡수할 캐시 계층이 필요했습니다.

왜 Redis(분산 캐시)인가?

로컬 캐시(Caffeine 등)는 단일 인스턴스에서는 빠르지만, Scale-out 환경에서 치명적인 문제가 있습니다. 인스턴스 A에서 Admin이 상품을 수정해도, 인스턴스 B의 로컬 캐시는 갱신되지 않아 사용자마다 다른 데이터를 보게 됩니다. Redis 분산 캐시를 선택하여 다중 인스턴스 환경에서도 단일 캐시 소스를 보장하고, 정합성 문제를 원천 차단했습니다.

왜 Cache-Aside인가?

캐시 패턴은 크게 Write-Through, Write-Behind, Cache-Aside 세 가지가 있습니다.

 

캐시 패턴

현재 프로젝트의 상품 조회 API는 전형적인 Read-Heavy 워크로드입니다. 상품 정보 변경(Admin 수정, 좋아요 토글)은 조회 대비 1% 미만이므로, 쓰기 경로에 캐시 로직이 전혀 개입하지 않는 Cache-Aside가 가장 적합하다고 판단했습니다.

// ProductAppService.java — Cache-Aside 패턴
  @Transactional(readOnly = true)
  public CachedProductDetail getProductDetailCached(Long productId) {
      // 1. 캐시 확인
      Optional<CachedProductDetail> cached = productCacheManager.getProductDetail(productId);
      if (cached.isPresent()) {
          return cached.get();
      }
      // 2. 캐시 미스 → DB 조회
      Product product = getById(productId);
      List<Option> options = optionRepository.findByProductId(productId);
      CachedProductDetail detail = CachedProductDetail.from(product, options, product.getLikeCount());
      // 3. 캐시 저장
      productCacheManager.putProductDetail(productId, detail);
      return detail;
  }

 


4. 부분 캐싱(Partial Caching)으로 Cache Pollution 방지

캐시를 적용할 때 "어디까지 캐싱할 것인가"에 대한 고민이 있었습니다. 모든 페이지를 무차별적으로 캐싱하면 크롤러나 딥 페이징 탐색이 저빈도 데이터까지 Redis 메모리에 채워넣는 Cache Pollution(메모리 오염)이 발생하고, 정작 트래픽이 집중되는 핫 데이터가 eviction으로 밀려날 위험이 있습니다.

 

커머스 도메인에서 사용자 트래픽의 90% 이상은 1~3페이지에 집중됩니다. 이 특성을 활용하여 CACHEABLE_PAGE_LIMIT = 2(page 0, 1, 2)로 캐싱 범위를 제한했습니다. 4페이지 이상은 Redis를 아예 접근하지 않고 DB에서 직접 조회합니다.

  // ProductAppService.java
  private static final int CACHEABLE_PAGE_LIMIT = 2;

  public CachedBrandProductPage getProductsByBrandIdCached(Long brandId, int page, int size) {
      if (page <= CACHEABLE_PAGE_LIMIT) {
          Optional<CachedBrandProductPage> cached = productCacheManager.getProductList(brandId, page, size);
          if (cached.isPresent()) {
              return cached.get();
          }
      }

      Page<Product> products = productRepository.findByBrandIdWithPaging(brandId, PageRequest.of(page, size));
      CachedBrandProductPage result = CachedBrandProductPage.builder()
              .content(products.getContent().stream().map(ProductSummary::from).toList())
              .totalElements(products.getTotalElements())
              .build();

      if (page <= CACHEABLE_PAGE_LIMIT) {
          productCacheManager.putProductList(brandId, page, size, result);
      }
      return result;
  }

4페이지 이상이 캐시 없이 DB를 직접 타도 괜찮은 이유는, 앞서 설계한 복합 인덱스가 1,000만 건에서도 0.53ms 이내로 응답을 보장하기 때문입니다. 인덱스가 캐시 미스의 비용을 감당해주는 구조입니다.

 


5. TTL Jitter와 이벤트 성격별 무효화 전략

캐시 설계에서 가장 어려웠던 부분은 "언제, 어떻게 캐시를 지울 것인가"였습니다.

TTL Jitter — Cache Stampede 방어

동일한 TTL로 캐시가 동시에 만료되면, 순간적으로 DB에 요청이 폭주하는 Cache Stampede(Thundering Herd)가 발생할 수 있습니다. 이를 방어하기 위해 TTL에 랜덤 Jitter를 추가하여 만료 시점을 분산시켰습니다.

  // RedisProductCacheManager.java
  private static final long LIST_BASE_TTL_SECONDS = 60;     // 목록: 1분
  private static final long LIST_JITTER_BOUND = 10;          // +1~10초 랜덤
  private static final long DETAIL_BASE_TTL_SECONDS = 300;   // 상세: 5분
  private static final long DETAIL_JITTER_BOUND = 30;        // +1~30초 랜덤

  private Duration listTtlWithJitter() {
      long jitter = ThreadLocalRandom.current().nextLong(1, LIST_JITTER_BOUND + 1);
      return Duration.ofSeconds(LIST_BASE_TTL_SECONDS + jitter);
  }

같은 브랜드의 page 0, 1, 2 캐시가 동시에 저장되더라도, 각각 61초, 67초, 63초처럼 만료 시점이 흩어져 DB로의 동시 요청을 줄여줍니다.

이벤트 성격별 무효화 전략 분리

모든 데이터 변경을 동일하게 처리하는 것은 비효율적입니다. 이벤트의 빈도와 정합성 요구 수준에 따라 전략을 분리했습니다.

 

이벤트 별 전략 분리

  // AdminProductAppService.java — Admin 변경 시에만 즉시 무효화
  @Transactional
  public Product update(Long id, String name, Money basePrice) {
      Product product = getById(id);
      product.update(name, basePrice);
      productCacheManager.evictProductCaches(id, product.getBrandId());
      return product;
  }

좋아요 토글 경로(LikeFacade.toggleLike())에서는 evictProductCaches()를 호출하지 않습니다. DB의 likeCount는 원자적으로 즉시 갱신되지만, 캐시에는 TTL이 만료될 때까지 이전 값이 남아있습니다. 이 정도의 지연(최대 70초)은 커머스 도메인에서 충분히 허용 가능한 수준이라고 판단했습니다.

 


6. Fail-Fast! Redis가 죽어도 서비스는 살아야 한다

Cache-Aside 패턴의 가장 큰 장점은 캐시가 장애나도 서비스가 멈추지 않는다는 점입니다. Redis를 필수 의존성이 아닌 성능 가속기로 취급하기 위해 두 가지 장치를 적용했습니다.

 

Fail-Fast 타임아웃: 캐시 조회가 DB 직접 조회(인덱스 적용 시 0.2ms)보다 느려지는 상황을 차단합니다.

  // RedisConfig.java
  private static final Duration COMMAND_TIMEOUT = Duration.ofMillis(500);
  private static final Duration CONNECT_TIMEOUT = Duration.ofMillis(300);

예외 흡수: Redis에서 어떤 예외가 발생해도 Optional.empty()를 반환하여, 자연스럽게 DB fallback으로 전환됩니다.

[정상 흐름] Request → Redis HIT → Response (< 1ms)
[캐시 미스] Request → Redis MISS → DB 조회(0.2ms) → Redis PUT → Response
[Redis 장애] Request → Redis 예외(흡수) → DB 조회(0.2ms) → Response

 

세 경로 모두 사용자에게는 동일한 응답이 반환됩니다. 성능만 다를 뿐 기능은 완전히 동일합니다.

 


P.S.

성능 최적화는 여러 계층의 방어가 시너지를 내는 것이라 생각합니다.

 

인덱스가 없었다면 캐시 미스 한 번에 1,000만 건 기준 1,440ms가 소요되어 Stampede 시 DB가 무너졌을 것이고, 캐시가 없었다면 아무리 빠른 인덱스 쿼리라도 초당 수백 회 반복 실행은 커넥션 풀을 고갈시켰을 것입니다. 비정규화가 없었다면 인덱스 자체를 설계할 수 없었을 것이고요. 비정규화 → 인덱스 → 캐시는 순서가 있는 파이프라인이며, 각 단계가 다음 단계의 전제 조건이 된다고 보면 되겠습니다.

반응형
TOP