diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index ec392a2fb..fefacacbe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,6 +6,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.product.ProductCacheStore; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -20,10 +21,11 @@ public class ProductFacade { private final BrandService brandService; private final ProductService productService; + private final ProductCacheStore productCacheStore; public List createProducts(CreateProductCommand command) { command.products().keySet().forEach(brandService::getById); - + Map domainRequest = new HashMap<>(); command.products().forEach((brandId, item) -> { domainRequest.put(brandId, new CreateProductRequest( @@ -32,7 +34,7 @@ public List createProducts(CreateProductCommand command) { item.stock() )); }); - + List products = productService.createProducts(domainRequest); return products.stream() .map(product -> { @@ -44,29 +46,39 @@ public List createProducts(CreateProductCommand command) { @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { - Product product = productService.getById(productId); - Brand brand = brandService.getById(product.getRefBrandId()); - return ProductInfo.of(product, brand); + return productCacheStore.getProduct(productId) + .orElseGet(() -> { + Product product = productService.getById(productId); + Brand brand = brandService.getById(product.getRefBrandId()); + ProductInfo info = ProductInfo.of(product, brand); + productCacheStore.putProduct(productId, info); + return info; + }); } @Transactional(readOnly = true) public List getProducts(ProductSearchCommand command) { - if (command.hasBrandId()) { - brandService.getById(command.brandId()); - } - - ProductSearchCondition condition = new ProductSearchCondition( - command.brandId(), - command.sortType(), - command.page(), - command.size() - ); - - return productService.findProducts(condition).stream() - .map(product -> { - Brand brand = brandService.getById(product.getRefBrandId()); - return ProductInfo.of(product, brand); - }) - .toList(); + return productCacheStore.getProducts(command) + .orElseGet(() -> { + if (command.hasBrandId()) { + brandService.getById(command.brandId()); + } + + ProductSearchCondition condition = new ProductSearchCondition( + command.brandId(), + command.sortType(), + command.page(), + command.size() + ); + + List list = productService.findProducts(condition).stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); + productCacheStore.putProducts(command, list); + return list; + }); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java new file mode 100644 index 000000000..1bd80be9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java @@ -0,0 +1,98 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSearchCommand; +import com.loopers.config.redis.RedisConfig; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ProductCacheStore { + + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String LIST_KEY_PREFIX = "product:list:"; + + private static final Duration DETAIL_TTL = Duration.ofSeconds(180); + private static final Duration LIST_TTL = Duration.ofSeconds(30); + + private final RedisTemplate redisTemplate; + private final RedisTemplate masterRedisTemplate; + private final ObjectMapper objectMapper; + + public ProductCacheStore( + RedisTemplate redisTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate, + ObjectMapper objectMapper + ) { + this.redisTemplate = redisTemplate; + this.masterRedisTemplate = masterRedisTemplate; + this.objectMapper = objectMapper; + } + + public Optional getProduct(Long productId) { + String key = detailKey(productId); + try { + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(value, ProductInfo.class)); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize product cache. key={}", key, e); + return Optional.empty(); + } + } + + public void putProduct(Long productId, ProductInfo productInfo) { + String key = detailKey(productId); + try { + masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfo), DETAIL_TTL); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize product cache. key={}", key, e); + } + } + + public Optional> getProducts(ProductSearchCommand command) { + String key = listKey(command); + try { + String value = redisTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(value, new TypeReference<>() {})); + } catch (JsonProcessingException e) { + log.warn("Failed to deserialize product list cache. key={}", key, e); + return Optional.empty(); + } + } + + public void putProducts(ProductSearchCommand command, List productInfos) { + String key = listKey(command); + try { + masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfos), LIST_TTL); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize product list cache. key={}", key, e); + } + } + + private String detailKey(Long productId) { + return DETAIL_KEY_PREFIX + productId; + } + + private String listKey(ProductSearchCommand command) { + return LIST_KEY_PREFIX + + "brandId=" + command.brandId() + + ":sort=" + command.sortType() + + ":page=" + command.page() + + ":size=" + command.size(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 4c572d649..700645f9d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -4,6 +4,7 @@ import com.loopers.domain.product.Product; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; @@ -12,7 +13,12 @@ * Product DB 엔티티 */ @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"), + @Index(name = "idx_product_like", columnList = "like_count DESC"), + @Index(name = "idx_product_brand_latest", columnList = "ref_brand_id, created_at DESC"), + @Index(name = "idx_product_latest", columnList = "created_at DESC") +}) @NoArgsConstructor public class ProductEntity extends BaseEntity { diff --git a/db/seed-products.sql b/db/seed-products.sql new file mode 100644 index 000000000..3d76d5d32 --- /dev/null +++ b/db/seed-products.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- 상품 더미 데이터 시드 스크립트 (10만 건) +-- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다. +-- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql +-- ============================================================ + +-- 브랜드 10개 삽입 +INSERT INTO brand (name, description, created_at, updated_at) +VALUES + ('나이키', '글로벌 스포츠 브랜드', NOW(), NOW()), + ('아디다스', '독일 스포츠 브랜드', NOW(), NOW()), + ('뉴발란스', '미국 스포츠 브랜드', NOW(), NOW()), + ('컨버스', '캐주얼 스니커즈 브랜드', NOW(), NOW()), + ('반스', '스케이트 브랜드', NOW(), NOW()), + ('푸마', '독일 스포츠 브랜드', NOW(), NOW()), + ('리복', '영국 스포츠 브랜드', NOW(), NOW()), + ('언더아머', '미국 퍼포먼스 브랜드', NOW(), NOW()), + ('살로몬', '아웃도어 브랜드', NOW(), NOW()), + ('노스페이스','아웃도어 브랜드', NOW(), NOW()); + +-- 상품 10만 건 삽입 (cross join으로 빠르게 생성) +SET @i = 0; + +INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at) +SELECT + CONCAT('상품_', @i := @i + 1), + FLOOR(1 + RAND() * 10), + FLOOR(1 + RAND() * 100) * 1000, + FLOOR(RAND() * 500), + FLOOR(RAND() * 1000), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + NOW() +FROM + information_schema.columns a, + information_schema.columns b +LIMIT 100000; diff --git a/docs/cache-performance-test.md b/docs/cache-performance-test.md new file mode 100644 index 000000000..35d6952a0 --- /dev/null +++ b/docs/cache-performance-test.md @@ -0,0 +1,227 @@ +# 상품 조회 캐시 성능 테스트 + +## 1. 문제 인식 + +### 현재 상황 + +상품 상세 API와 상품 목록 API는 매 요청마다 DB를 조회한다. + +**상품 상세 조회 (`getProduct`)** +``` +productService.getById(productId) → DB 쿼리 1번 (product) +brandService.getById(brandId) → DB 쿼리 1번 (brand) +``` + +**상품 목록 조회 (`getProducts`)** +``` +productService.findProducts(condition) → DB 쿼리 1번 (products) +brandService.getById(brandId) × N → DB 쿼리 N번 (N+1 문제) +``` + +### 의문점 + +상품 정보(이름, 가격, 브랜드)는 자주 변경되지 않는다. +동일한 상품을 수천 명이 반복 조회할 때, 매번 DB를 거치는 것이 합리적인가? + +**→ Redis 캐시를 도입해서 비교해보자.** + +--- + +## 2. 무엇을 캐시할까? + +### 캐시 전략 결정 + +Cache Aside 패턴을 사용한다. + +``` +읽기: 캐시 확인 → 히트 시 즉시 반환, 미스 시 DB 조회 후 캐시 저장 +쓰기: 캐시를 갱신하지 않음 → TTL 만료로 자연스럽게 갱신 +``` + +### TTL 설계 + +| API | TTL | 이유 | +|-----|-----|------| +| 상품 상세 | 3분 (180s) | 가격/브랜드 정보는 자주 안 바뀜 | +| 상품 목록 | 30초 | 무효화 없이 TTL만 사용하므로 짧게 | + +### 캐시 키 설계 + +``` +상품 상세: product:detail:{productId} +상품 목록: product:list:brandId={brandId}:sort={sortType}:page={page}:size={size} +``` + +--- + +## 3. 테스트 설계 + +### 테스트 환경 + +- Java 21 / Spring Boot 3.4.4 +- MySQL 8.x (Docker, port 3307) +- Redis Master-Replica (Docker, port 6379/6380) +- 총 상품 수: 100,000개 / 브랜드 수: 10개 + +### 측정 방식 + +**캐시 미스 (DB 직접 조회 시뮬레이션)** +- 매번 다른 ID로 요청 → 캐시 저장된 데이터 없음 → 항상 DB 조회 +- 100회 순차 요청 후 평균 측정 + +**캐시 히트** +- Redis FLUSHDB로 초기화 후 동일 ID/파라미터로 반복 요청 +- 첫 요청(미스)으로 캐시 워밍 후 ab로 1,000회 × 동시 10로 측정 + +### 측정 도구 + +- `curl -w "%{time_total}"`: 캐시 미스 개별 응답시간 +- `ab -n 1000 -c 10`: 캐시 히트 처리량 및 평균 응답시간 + +--- + +## 4. 테스트 결과 + +### 상품 상세 조회 `GET /api/v1/products/{productId}` + +| 구분 | 평균 응답시간 | 처리량 | +|------|------------|--------| +| 캐시 미스 (DB 조회) | 15.5ms | ~65 req/s | +| 캐시 히트 (Redis) | **0.7ms** | **1,401 req/s** | + +### 상품 목록 조회 `GET /api/v1/products` + +| 구분 | 평균 응답시간 | 처리량 | +|------|------------|--------| +| 캐시 미스 (DB 조회) | 49.1ms | ~20 req/s | +| 캐시 히트 (Redis) | **5.4ms** | **184 req/s** | + +--- + +## 5. 예상과 다른 결과들 + +### 발견 1: 목록 API의 캐시 미스가 상세보다 3배 느리다 + +상세 API 미스: 15.5ms +목록 API 미스: 49.1ms + +**왜 이렇게 차이가 날까?** + +목록 API는 캐시 미스 시 **N+1 문제**가 발생한다. + +```java +// 상품 10개 조회 후, 각 상품마다 brand 쿼리 발생 +productService.findProducts(condition) // 쿼리 1번 + .map(product -> brandService.getById(...)) // 쿼리 N번 (10번) +``` + +``` +캐시 미스 시 총 DB 왕복: 1 (상품 목록) + 10 (브랜드) = 11번 +캐시 히트 시 DB 왕복: 0번 +``` + +캐시 히트가 N+1을 통째로 회피하기 때문에 목록 API에서 개선 효과가 더 극적으로 나타난다. + +### 발견 2: 상세 API의 캐시 히트가 0.7ms로 극단적으로 빠르다 + +상세 API 미스: 15.5ms → 히트: 0.7ms (22배 향상) + +캐시 히트 시 Spring 애플리케이션은 Redis에서 JSON을 읽어 역직렬화만 하면 된다. + +``` +캐시 미스: HTTP 수신 → ProductService → DB → BrandService → DB → JSON 직렬화 → HTTP 응답 +캐시 히트: HTTP 수신 → Redis GET → JSON 역직렬화 → HTTP 응답 +``` + +DB 왕복 2번이 사라지면서 처리 경로가 대폭 단축된다. + +### 발견 3: 처리량 차이가 응답시간 차이보다 더 크다 + +| | 응답시간 개선 | 처리량 개선 | +|--|-------------|-----------| +| 상품 상세 | 22배 빠름 | 21배 향상 | +| 상품 목록 | 9배 빠름 | 9배 향상 | + +응답시간이 빨라지면 동시 처리 가능한 요청 수도 함께 늘어나므로 처리량 개선 비율도 유사하게 나타난다. + +--- + +## 6. 추가 질문: 로컬 환경에서 측정했는데 프로덕션에서도 같은 효과가 날까? + +### 환경 차이 + +로컬 환경에서는 앱, DB, Redis가 모두 같은 머신에 있어 네트워크 레이턴시가 거의 없다. +프로덕션에서는 각 서버가 분리되어 있어 DB 왕복 레이턴시가 훨씬 크다. + +| 항목 | 로컬 | 프로덕션 (예상) | +|------|------|----------------| +| DB 왕복 레이턴시 | ~1ms | 5~20ms | +| Redis 왕복 레이턴시 | ~0.1ms | 1~3ms | +| 캐시 미스 응답시간 | 15~50ms | 50~200ms | +| 캐시 히트 응답시간 | 0.7~5ms | 2~10ms | + +**DB 레이턴시가 높을수록 캐시 효과는 더 크게 나타난다.** + +특히 목록 API의 N+1 문제는 프로덕션에서 훨씬 치명적이다. +브랜드 쿼리 10번 × 10ms = 100ms가 캐시 히트 시 5ms 이내로 줄어든다. + +--- + +## 7. 캐시 도입의 트레이드오프 + +캐시가 항상 정답은 아니다. 단점도 알아야 올바른 선택을 할 수 있다. + +### 7-1. 데이터 불일치 (Stale Data) + +캐시 TTL 동안 DB 값이 바뀌어도 캐시는 이전 값을 반환한다. + +``` +1. 상품 가격: DB = 10,000원, 캐시 = 10,000원 ← 일치 +2. 관리자가 가격 변경: DB = 8,000원 +3. TTL 만료 전 조회: DB = 8,000원, 캐시 = 10,000원 ← 불일치 +4. TTL 만료 후 조회: DB = 8,000원, 캐시 = 8,000원 ← 일치 +``` + +**본 설계에서의 선택**: TTL(상세 3분, 목록 30초)을 짧게 설정해 불일치 시간을 최소화했다. +단, 가격 변경이 실시간으로 반영되어야 하는 요구사항이 생기면 `@CacheEvict`로 무효화 전략 추가가 필요하다. + +### 7-2. 목록 캐시 무효화의 어려움 + +목록 API는 `brandId`, `sortType`, `page`, `size` 조합으로 캐시 키가 생성된다. +상품 하나가 추가/변경될 때 영향받는 캐시 키를 특정하기 어렵다. + +``` +product:list:brandId=1:sort=LATEST:page=0:size=10 +product:list:brandId=1:sort=LATEST:page=1:size=10 +product:list:brandId=null:sort=LATEST:page=0:size=10 +... (조합이 무한히 늘어남) +``` + +**본 설계에서의 선택**: TTL(30초)만으로 관리. 신규 상품은 최대 30초 후 목록에 반영된다. + +### 7-3. Redis 장애 시 영향 + +Redis가 다운되면 모든 요청이 DB로 향한다. +현재 코드는 `JsonProcessingException` 발생 시 캐시 미스로 처리하도록 방어 코드가 있지만, +Redis 연결 자체가 끊기면 예외가 전파되어 API 전체 장애로 이어질 수 있다. + +**개선 방향**: Redis 연결 예외를 catch해서 캐시 미스로 폴백하는 처리 추가 검토 필요. + +--- + +## 8. 최종 결론 + +### 질문에 대한 답 + +| 질문 | 답 | +|------|-----| +| 캐시 도입이 효과가 있는가? | 상세 22배, 목록 9배 응답속도 개선 | +| 목록보다 상세가 더 효과적인가? | 절대 개선폭은 상세가 크지만, N+1 회피로 목록도 극적으로 개선 | +| 프로덕션에서도 같은 효과인가? | DB 레이턴시가 높을수록 효과가 더 크게 나타남 | +| 트레이드오프는 없는가? | Stale Data, 목록 무효화 어려움, Redis 장애 전파 위험 존재 | + +### 남은 고려사항 + +- Redis 연결 장애 시 폴백 처리 추가 검토 +- 가격/재고 변경 API 추가 시 `@CacheEvict` 무효화 전략 연결 필요 +- 목록 API의 N+1 문제는 캐시와 별개로 근본 해결(배치 조회) 검토 가치 있음 diff --git a/docs/index-performance-test.md b/docs/index-performance-test.md new file mode 100644 index 000000000..b2195667b --- /dev/null +++ b/docs/index-performance-test.md @@ -0,0 +1,431 @@ +# 상품 조회 인덱스 성능 테스트 + +## 1. 문제 인식 + +### 현재 상황 + +상품 목록 API에서 **브랜드 필터 + 좋아요 순 정렬** 기능을 제공하고 있다. + +```sql +SELECT * FROM product +WHERE ref_brand_id = ? +ORDER BY like_count DESC +LIMIT 10; +``` + +### 의문점 + +현재 `product` 테이블에 PRIMARY KEY만 있는 상태에서 EXPLAIN을 실행해봤다. + +``` +type: ALL (풀 테이블 스캔) +rows: 99,694 +Extra: Using where; Using filesort +``` + +**10만 개 전체를 스캔하고 있다.** 인덱스가 필요해 보인다. + +--- + +## 2. 어떤 인덱스를 추가해야 할까? + +### 후보 인덱스 + +| 인덱스 | 이유 | +|--------|------| +| `ref_brand_id` | WHERE 조건에 사용됨 | +| `like_count DESC` | ORDER BY에 사용됨 | +| `(ref_brand_id, like_count DESC)` | 둘 다 커버 | + +### 첫 번째 질문: 무조건 복합 인덱스가 좋을까? + +복합 인덱스가 WHERE + ORDER BY를 모두 커버하니까 무조건 좋을 것 같았다. + +하지만 고민이 생겼다: +- 복합 인덱스는 크기가 더 크다 +- 쓰기 성능에 영향을 줄 수 있다 +- 단일 인덱스로 충분하다면 굳이? + +**→ 실제로 테스트해서 비교해보자.** + +### 두 번째 질문: 데이터 분포가 중요할까? + +"브랜드당 상품 수"가 다르면 인덱스 효과가 달라질까? + +| 상황 | 예시 | +|------|------| +| 대형 브랜드 소수 | 나이키, 아디다스만 있고 각각 상품 1만개 | +| 소형 브랜드 다수 | 셀러 1000명이 각각 상품 100개씩 | + +**→ 데이터 분포별로 테스트해보자.** + +--- + +## 3. 테스트 설계 + +### 테스트 환경 +- MySQL 8.x (Docker) +- 총 상품 수: 100,000개 + +### 테스트 케이스 + +**데이터 분포:** + +| 케이스 | 브랜드 수 | 브랜드당 상품 | 시나리오 | +|-------|---------|-------------|---------| +| A | 10 | 10,000 | 대형 브랜드 소수 | +| B | 100 | 1,000 | 중형 브랜드 | +| C | 1,000 | 100 | 소형 브랜드 다수 | + +**인덱스 조합:** +- 좋아요순: 4가지 (없음, ref_brand_id, like_count, 복합) +- 최신순: 3가지 (없음, created_at, 복합) + +**쿼리 패턴:** +- Q1: 브랜드 필터 + 좋아요순 +- Q2: 전체 좋아요순 (브랜드 필터 없음) +- Q3: 브랜드 필터 + 최신순 +- Q4: 전체 최신순 (브랜드 필터 없음) + +왜 Q2, Q4도 테스트할까? → API에서 `brandId`가 선택적 파라미터라서 둘 다 사용될 수 있다. + +--- + +## 4. 테스트 결과 + +### Q1: 브랜드 필터 + 좋아요순 (단위: ms) + +| 인덱스 | 케이스A (10브랜드) | 케이스B (100브랜드) | 케이스C (1000브랜드) | +|--------|-------------------|--------------------|--------------------| +| 없음 | 29.2 | 20.4 | 27.3 | +| ref_brand_id | 21.8 | 1.01 | **0.17** | +| like_count | **0.28** | 0.58 | 6.27 | +| 복합 | **0.21** | **0.37** | **0.02** | + +### Q2: 전체 좋아요순 (단위: ms) + +| 인덱스 | 케이스A | 케이스B | 케이스C | +|--------|--------|--------|--------| +| 없음 | 33.0 | 34.7 | 30.7 | +| ref_brand_id | 32.1 | 29.6 | 30.1 | +| like_count | **0.05** | **0.05** | **0.05** | +| 복합 | 33.6 | 21.4 | 33.2 | + +### Q3: 브랜드 필터 + 최신순 (단위: ms) + +| 인덱스 | 결과 | 비고 | +|--------|------|------| +| 없음 | 0.31 | brand 인덱스 사용 + filesort | +| created_at | 0.27 | brand 인덱스 사용 + filesort | +| 복합 (brand, created_at) | **0.04** ✅ | filesort 없음 | + +### Q4: 전체 최신순 (단위: ms) + +| 인덱스 | 결과 | 비고 | +|--------|------|------| +| 없음 | 22.2 | 풀 테이블 스캔 | +| created_at | **0.05** ✅ | 인덱스 스캔 | +| 복합 (brand, created_at) | 0.03 | created_at 인덱스 사용 | + +--- + +## 5. 예상과 다른 결과들 + +### 발견 1: 데이터 분포에 따라 최적 인덱스가 달라진다 + +**케이스 A (브랜드 10개, 브랜드당 10,000개)** +- `like_count` 인덱스: 0.28ms ✅ +- `ref_brand_id` 인덱스: 21.8ms ❌ + +**케이스 C (브랜드 1000개, 브랜드당 100개)** +- `like_count` 인덱스: 6.27ms ❌ +- `ref_brand_id` 인덱스: 0.17ms ✅ + +**왜 이런 차이가 날까?** + +`like_count` 인덱스 동작 방식: +``` +좋아요 순으로 스캔하면서 해당 브랜드 찾기 +``` + +- 브랜드당 상품 많음 (10,000개) → 금방 찾음 (92개 스캔) +- 브랜드당 상품 적음 (100개) → 오래 걸림 (10,768개 스캔!) + +**EXPLAIN ANALYZE 결과:** + +케이스 A - like_count 인덱스: +``` +Index scan by like_count (92 rows) + → Filter by brand_id (10 rows) + → Limit 10 +⏱️ 0.28ms +``` + +케이스 C - like_count 인덱스: +``` +Index scan by like_count (10,768 rows) ← 10개 찾으려고 10,768개 스캔! + → Filter by brand_id (10 rows) + → Limit 10 +⏱️ 6.27ms +``` + +### 발견 2: 복합 인덱스는 데이터 분포와 무관하게 안정적 + +| 케이스 | 복합 인덱스 | +|-------|-----------| +| A | 0.21ms | +| B | 0.37ms | +| C | 0.02ms | + +모든 케이스에서 0.02 ~ 0.37ms로 안정적이다. + +``` +Index lookup by brand_id + like_count (10 rows) + → Limit 10 +⏱️ 0.02ms +``` + +인덱스에서 바로 10개만 가져온다. 필터링이나 정렬이 필요 없다. + +### 발견 3: 복합 인덱스는 Q2에 사용 불가 + +Q2 (전체 좋아요순)에서 복합 인덱스가 있어도: +``` +Table scan (100,000 rows) + → Sort by like_count + → Limit 10 +⏱️ 33.6ms +``` + +복합 인덱스 `(ref_brand_id, like_count)`는 `ref_brand_id` 조건이 없으면 사용할 수 없다. + +### 발견 4: 최신순도 동일한 패턴 + +Q3, Q4 테스트 결과 최신순(created_at) 정렬도 좋아요순과 동일한 패턴을 보인다. + +**Q3 (브랜드 필터 + 최신순)** + +인덱스 없을 때: +``` +Index lookup by brand_id (100 rows) + → Sort by created_at DESC + → Limit 10 +⏱️ 0.31ms +``` + +복합 인덱스 `(brand_id, created_at DESC)` 적용 후: +``` +Index lookup by brand_id + created_at (10 rows) + → Limit 10 +⏱️ 0.04ms +``` + +**Q4 (전체 최신순)** + +인덱스 없을 때: +``` +Table scan (100,000 rows) + → Sort by created_at DESC + → Limit 10 +⏱️ 22.2ms +``` + +`created_at` 단일 인덱스 적용 후: +``` +Index scan by created_at (10 rows) + → Limit 10 +⏱️ 0.05ms +``` + +**결론**: 좋아요순과 마찬가지로 최신순도 복합 인덱스 + 단일 인덱스 조합이 필요하다. + +--- + +## 6. 추가 질문: 페이지네이션은 괜찮을까? + +인덱스가 있으면 깊은 페이지(OFFSET이 큰 경우)도 빠를까? + +### 테스트 + +```sql +SELECT * FROM product ORDER BY like_count DESC LIMIT 10 OFFSET ?; +``` + +### 결과 + +| OFFSET | 페이지 | 인덱스 없음 | like_count 인덱스 | +|--------|-------|-----------|------------------| +| 0 | 1 | 28.1ms | **0.27ms** ✅ | +| 1,000 | 101 | 34.0ms | 33.9ms ❌ | +| 10,000 | 1,001 | 34.8ms | 42.6ms ❌ | + +### 예상과 다른 결과 + +OFFSET이 커지면 **인덱스가 있어도 풀 스캔**을 한다! + +OFFSET 0: +``` +Index scan by like_count (10 rows) + → Limit 10 +⏱️ 0.27ms +``` + +OFFSET 1000: +``` +Table scan (100,000 rows) + → Sort by like_count (1,010 rows) + → Skip 1,000 rows + → Limit 10 +⏱️ 33.9ms +``` + +**왜?** MySQL 옵티마이저가 "어차피 1000개 건너뛰려면 많이 읽어야 하니까 풀 스캔이 낫다"고 판단한다. + +### 결론 + +**인덱스로는 깊은 페이지네이션 문제를 해결할 수 없다.** + +대안: +- 커서 기반 페이지네이션: `WHERE like_count < ? LIMIT 10` +- 페이지 수 제한: 최대 100페이지까지만 허용 + +--- + +## 7. 추가 질문: 쓰기 성능은 괜찮을까? + +인덱스를 추가하면 좋아요 증가(UPDATE) 시 느려지지 않을까? + +### 테스트 + +```sql +UPDATE product SET like_count = like_count + 1 WHERE id = ?; +-- 1000회 반복 +``` + +### 결과 + +| 인덱스 | 1000회 UPDATE | 평균/회 | 성능 저하 | +|-------|--------------|--------|----------| +| 없음 | 604ms | 0.60ms | - | +| like_count | 689ms | 0.69ms | **14% 느림** | +| like_count + 복합 | 752ms | 0.75ms | **25% 느림** | + +### 분석 + +인덱스 없음: +``` +UPDATE data +⏱️ 0.60ms/회 +``` + +인덱스 2개: +``` +UPDATE data + → UPDATE idx_like_count (인덱스 재정렬) + → UPDATE idx_brand_like (인덱스 재정렬) +⏱️ 0.75ms/회 +``` + +### 결론 + +- 인덱스 2개 추가 시 쓰기 25% 느려짐 +- 일반적인 서비스에서는 감수할 만한 수준 +- 초당 수천 건의 좋아요가 발생하면 Redis 캐시나 비동기 처리 고려 필요 + +--- + +## 8. 최종 결론 + +### 질문에 대한 답 + +| 질문 | 답 | +|------|-----| +| 무조건 복합 인덱스가 좋을까? | Q1에는 최적이지만, Q2에는 사용 불가 | +| 데이터 분포가 중요할까? | 단일 인덱스 선택 시 매우 중요, 복합 인덱스는 무관 | +| 페이지네이션도 빨라질까? | 첫 페이지만 빠름, 깊은 페이지는 인덱스 무용지물 | +| 쓰기 성능은 괜찮을까? | 25% 느려지지만 감수할 만함 | + +### 인덱스 선택 + +| 쿼리 패턴 | 추천 인덱스 | +|----------|------------| +| Q1만 사용 | `(ref_brand_id, like_count DESC)` 복합 | +| Q2만 사용 | `(like_count DESC)` 단일 | +| 둘 다 사용 | **두 인덱스 모두 필요** | + +### 적용할 인덱스 + +처음에는 좋아요순만 고려해서 2개 인덱스를 설계했다: + +```sql +-- Q1 커버: 브랜드 필터 + 좋아요순 +CREATE INDEX idx_product_brand_like ON product(ref_brand_id, like_count DESC); + +-- Q2 커버: 전체 좋아요순 +CREATE INDEX idx_product_like ON product(like_count DESC); +``` + +### 추가 인덱스가 필요한가? + +잠깐, API를 다시 확인해보자. + +```java +@RequestParam(defaultValue = "LATEST") ProductSortType sortType +``` + +**기본 정렬이 최신순(LATEST)이다!** 그리고 정렬 옵션이 3가지가 있다: + +```java +case LATEST -> productEntity.createdAt.desc(); // 최신순 +case PRICE_ASC -> productEntity.price.asc(); // 가격순 +case LIKES_DESC -> productEntity.likeCount.desc(); // 좋아요순 +``` + +현재 인덱스는 좋아요순만 커버한다. 최신순과 가격순은? + +### 우선순위 결정 + +| 정렬 | 사용 빈도 | 인덱스 필요성 | +|------|----------|-------------| +| 최신순 (LATEST) | ⭐ 기본값이므로 가장 많이 호출 | **필수** | +| 좋아요순 (LIKES_DESC) | 이미 구현됨 | ✅ 있음 | +| 가격순 (PRICE_ASC) | 사용 빈도 불명 | 보류 | + +**최신순이 기본값**이므로 가장 많이 호출될 가능성이 높다. 인덱스 없이 방치하면 안 된다. + +가격순은 실제 트래픽을 보고 나중에 결정해도 된다. + +### 최종 적용 인덱스 (4개) + +```sql +-- 좋아요순 +CREATE INDEX idx_product_brand_like ON product(ref_brand_id, like_count DESC); +CREATE INDEX idx_product_like ON product(like_count DESC); + +-- 최신순 (추가) +CREATE INDEX idx_product_brand_latest ON product(ref_brand_id, created_at DESC); +CREATE INDEX idx_product_latest ON product(created_at DESC); +``` + +| 인덱스 | 커버하는 쿼리 | +|--------|-------------| +| `idx_product_brand_like` | 브랜드 필터 + 좋아요순 | +| `idx_product_like` | 전체 좋아요순 | +| `idx_product_brand_latest` | 브랜드 필터 + 최신순 | +| `idx_product_latest` | 전체 최신순 | + +### 가격순은 왜 안 만들었나? + +- 인덱스가 많아지면 쓰기 성능 저하 (테스트에서 25% 느려짐 확인) +- 가격순의 실제 사용 빈도를 모름 +- 필요하면 나중에 추가해도 됨 + +**트레이드오프**: 모든 경우를 커버하는 6개 인덱스 vs 핵심만 커버하는 4개 인덱스 + +→ 쓰기 성능을 고려해서 **4개로 시작**하고, 모니터링 후 필요하면 추가한다. + +### 남은 고려사항 + +- 깊은 페이지네이션은 커서 기반으로 변경 검토 +- 좋아요가 매우 빈번하면 Redis 캐시 검토 +- 가격순 정렬 사용량 모니터링 후 인덱스 추가 검토 \ No newline at end of file