diff --git a/.docs/index-benchmark-100k.md b/.docs/index-benchmark-100k.md new file mode 100644 index 000000000..4e5e195cb --- /dev/null +++ b/.docs/index-benchmark-100k.md @@ -0,0 +1,415 @@ +# 📊 상품 조회 인덱스 벤치마크 + +> 실행일시: 2026-03-09 23:36:02 +> MySQL 8.0 (TestContainers) + +## 📋 테스트 환경 + +| 항목 | 값 | +|---|---| +| 데이터 수 | 100,000건 | +| 브랜드 수 | 100개 (Zipf 분포) | +| 좋아요 분포 | Log-normal (중앙값 ~12, 롱테일) | +| 측정 횟수 | 3회 평균 | +| 인기 브랜드 ID | 1 (19,045건) | +| 중간 브랜드 ID | 49 (384건) | + +### 브랜드별 상품 수 (상위 10개) + +| 순위 | 브랜드 ID | 상품 수 | 비율 | +|---|---|---|---| +| 1 | 1 | 19,045 | 19.0% | +| 2 | 2 | 9,549 | 9.5% | +| 3 | 3 | 6,568 | 6.6% | +| 4 | 4 | 4,869 | 4.9% | +| 5 | 5 | 3,915 | 3.9% | +| 6 | 6 | 3,324 | 3.3% | +| 7 | 7 | 2,598 | 2.6% | +| 8 | 8 | 2,480 | 2.5% | +| 9 | 9 | 2,109 | 2.1% | +| 10 | 10 | 1,911 | 1.9% | + +### 좋아요 수 분포 + +| 구간 | 상품 수 | 비율 | +|---|---|---| +| 0~10 | 48,093 | 48.1% | +| 11~50 | 28,237 | 28.2% | +| 51~200 | 15,560 | 15.6% | +| 201~1,000 | 6,775 | 6.8% | +| 1,001~5,000 | 1,213 | 1.2% | +| 5,001+ | 122 | 0.1% | + +--- + +## 🔍 인덱스 없음 (Baseline) + +> PK(id)만 존재하는 상태 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ALL | NULL | NULL | 99642 | 1.0 | Using where; Using filesort | **28ms** | +| 2 | 브랜드(인기) + 가격순 | ALL | NULL | NULL | 99642 | 1.0 | Using where; Using filesort | **28ms** | +| 3 | 브랜드(인기) + 최신순 | ALL | NULL | NULL | 99642 | 1.0 | Using where; Using filesort | **28ms** | +| 4 | 브랜드(중간) + 좋아요순 | ALL | NULL | NULL | 99642 | 1.0 | Using where; Using filesort | **26ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **32ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **41ms** | +| 7 | COUNT(브랜드 인기) | ALL | NULL | NULL | 99642 | 1.0 | Using where | **15ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 99642 | 10.0 | Using where | **12ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **56ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ALL | NULL | NULL | 99642 | 1.0 | Using where; Using filesort | **33ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 49 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 단일 인덱스: (brand_id) + +> 가장 기본적인 필터 컬럼 인덱스 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_id | brand_id | 1 | 1 | + +```sql +CREATE INDEX idx_brand_id ON products(brand_id); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 37050 | 10.0 | Using where; Using filesort | **18ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_id | idx_brand_id | 37050 | 10.0 | Using where; Using filesort | **18ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_id | idx_brand_id | 37050 | 10.0 | Using where; Using filesort | **35ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 384 | 10.0 | Using where; Using filesort | **1ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **31ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **30ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_id | idx_brand_id | 37050 | 10.0 | Using where | **14ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 99642 | 10.0 | Using where | **12ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **54ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_id | idx_brand_id | 37050 | 10.0 | Using where; Using filesort | **21ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 49 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 복합 인덱스: (brand_id, deleted_at, like_count DESC) + +> 브랜드 필터 + soft delete + 좋아요순 정렬까지 커버 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_deleted_like | brand_id | 1 | 1 | +| idx_brand_deleted_like | deleted_at | 2 | 1 | +| idx_brand_deleted_like | like_count | 3 | 1 | + +```sql +CREATE INDEX idx_brand_deleted_like ON products(brand_id, deleted_at, like_count DESC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 37026 | 100.0 | Using index condition | **0ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 37026 | 100.0 | Using index condition; Using filesort | **22ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 37026 | 100.0 | Using index condition; Using filesort | **21ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 384 | 100.0 | Using index condition | **1ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **31ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **30ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_deleted_like | idx_brand_deleted_like | 37026 | 100.0 | Using where; Using index | **3ms** | +| 8 | COUNT(전체) | index | idx_brand_deleted_like | idx_brand_deleted_like | 99642 | 10.0 | Using where; Using index | **9ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 99642 | 10.0 | Using where; Using filesort | **55ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 37026 | 100.0 | Using index condition | **6ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 49 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 전체 커버링 인덱스 세트 + +> 모든 조회 패턴에 최적화된 인덱스 조합 (쓰기 비용 증가 트레이드오프) + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_prod_brand_like | brand_id | 1 | 1 | +| idx_prod_brand_like | deleted_at | 2 | 1 | +| idx_prod_brand_like | like_count | 3 | 1 | +| idx_prod_brand_price | brand_id | 1 | 1 | +| idx_prod_brand_price | deleted_at | 2 | 1 | +| idx_prod_brand_price | price | 3 | 1 | +| idx_prod_brand_created | brand_id | 1 | 1 | +| idx_prod_brand_created | deleted_at | 2 | 1 | +| idx_prod_brand_created | created_at | 3 | 1 | +| idx_prod_deleted_like | deleted_at | 1 | 1 | +| idx_prod_deleted_like | like_count | 2 | 1 | +| idx_prod_deleted_price | deleted_at | 1 | 1 | +| idx_prod_deleted_price | price | 2 | 1 | + +```sql +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 37026 | 100.0 | Using index condition | **0ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_price | 37026 | 100.0 | Using index condition | **1ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 37026 | 100.0 | Using index condition | **1ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 384 | 100.0 | Using index condition | **0ms** | +| 5 | 전체 + 좋아요순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 49821 | 100.0 | Using index condition | **1ms** | +| 6 | 전체 + 가격순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_price | 49821 | 100.0 | Using index condition | **0ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 37026 | 100.0 | Using where; Using index | **2ms** | +| 8 | COUNT(전체) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_deleted_like | 49821 | 100.0 | Using where; Using index | **10ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 49821 | 100.0 | Using index condition | **28ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 37026 | 100.0 | Using index condition | **8ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 49 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 📈 전략별 성능 비교 요약 + +| 쿼리 | 인덱스 없음 (Baseline) | 단일 인덱스: (brand_id) | 복합 인덱스: (brand_id, deleted_at, like_count DESC) | 전체 커버링 인덱스 세트 | +|---|---|---|---|---| +| 브랜드(인기) + 좋아요순 | 28ms | 18ms | 0ms | 0ms | +| 브랜드(인기) + 가격순 | 28ms | 18ms | 22ms | 1ms | +| 브랜드(인기) + 최신순 | 28ms | 35ms | 21ms | 1ms | +| 브랜드(중간) + 좋아요순 | 26ms | 1ms | 1ms | 0ms | +| 전체 + 좋아요순 | 32ms | 31ms | 31ms | 1ms | +| 전체 + 가격순 | 41ms | 30ms | 30ms | 0ms | +| COUNT(브랜드 인기) | 15ms | 14ms | 3ms | 2ms | +| COUNT(전체) | 12ms | 12ms | 9ms | 10ms | +| 딥페이징: 좋아요순 OFFSET 10000 | 56ms | 54ms | 55ms | 28ms | +| 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | 33ms | 21ms | 6ms | 8ms | diff --git a/.docs/index-benchmark-1m.md b/.docs/index-benchmark-1m.md new file mode 100644 index 000000000..b2c86ef8c --- /dev/null +++ b/.docs/index-benchmark-1m.md @@ -0,0 +1,415 @@ +# 📊 상품 조회 인덱스 벤치마크 + +> 실행일시: 2026-03-09 23:44:04 +> MySQL 8.0 (TestContainers) + +## 📋 테스트 환경 + +| 항목 | 값 | +|---|---| +| 데이터 수 | 1,000,000건 | +| 브랜드 수 | 100개 (Zipf 분포) | +| 좋아요 분포 | Log-normal (중앙값 ~12, 롱테일) | +| 측정 횟수 | 3회 평균 | +| 인기 브랜드 ID | 1 (192,381건) | +| 중간 브랜드 ID | 51 (3,796건) | + +### 브랜드별 상품 수 (상위 10개) + +| 순위 | 브랜드 ID | 상품 수 | 비율 | +|---|---|---|---| +| 1 | 1 | 192,381 | 19.2% | +| 2 | 2 | 95,894 | 9.6% | +| 3 | 3 | 64,495 | 6.4% | +| 4 | 4 | 48,220 | 4.8% | +| 5 | 5 | 38,807 | 3.9% | +| 6 | 6 | 32,031 | 3.2% | +| 7 | 7 | 27,455 | 2.7% | +| 8 | 8 | 24,185 | 2.4% | +| 9 | 9 | 21,253 | 2.1% | +| 10 | 10 | 19,345 | 1.9% | + +### 좋아요 수 분포 + +| 구간 | 상품 수 | 비율 | +|---|---|---| +| 0~10 | 479,535 | 48.0% | +| 11~50 | 283,415 | 28.3% | +| 51~200 | 156,275 | 15.6% | +| 201~1,000 | 66,948 | 6.7% | +| 1,001~5,000 | 12,474 | 1.2% | +| 5,001+ | 1,353 | 0.1% | + +--- + +## 🔍 인덱스 없음 (Baseline) + +> PK(id)만 존재하는 상태 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ALL | NULL | NULL | 995153 | 1.0 | Using where; Using filesort | **332ms** | +| 2 | 브랜드(인기) + 가격순 | ALL | NULL | NULL | 995153 | 1.0 | Using where; Using filesort | **347ms** | +| 3 | 브랜드(인기) + 최신순 | ALL | NULL | NULL | 995153 | 1.0 | Using where; Using filesort | **333ms** | +| 4 | 브랜드(중간) + 좋아요순 | ALL | NULL | NULL | 995153 | 1.0 | Using where; Using filesort | **302ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **351ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **342ms** | +| 7 | COUNT(브랜드 인기) | ALL | NULL | NULL | 995153 | 1.0 | Using where | **182ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 995153 | 10.0 | Using where | **156ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **620ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ALL | NULL | NULL | 995153 | 1.0 | Using where; Using filesort | **394ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 단일 인덱스: (brand_id) + +> 가장 기본적인 필터 컬럼 인덱스 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_id | brand_id | 1 | 1 | + +```sql +CREATE INDEX idx_brand_id ON products(brand_id); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 412896 | 10.0 | Using where; Using filesort | **1727ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_id | idx_brand_id | 412896 | 10.0 | Using where; Using filesort | **1711ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_id | idx_brand_id | 412896 | 10.0 | Using where; Using filesort | **1902ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 3796 | 10.0 | Using where; Using filesort | **22ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **366ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **375ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_id | idx_brand_id | 412896 | 10.0 | Using where | **1939ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 995153 | 10.0 | Using where | **181ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 995153 | 10.0 | Using where; Using filesort | **606ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_id | idx_brand_id | 412896 | 10.0 | Using where; Using filesort | **1678ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 복합 인덱스: (brand_id, deleted_at, like_count DESC) + +> 브랜드 필터 + soft delete + 좋아요순 정렬까지 커버 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_deleted_like | brand_id | 1 | 1 | +| idx_brand_deleted_like | deleted_at | 2 | 1 | +| idx_brand_deleted_like | like_count | 3 | 1 | + +```sql +CREATE INDEX idx_brand_deleted_like ON products(brand_id, deleted_at, like_count DESC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 414398 | 100.0 | Using index condition | **1ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 414398 | 100.0 | Using index condition; Using filesort | **1218ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 414398 | 100.0 | Using index condition; Using filesort | **1269ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 3796 | 100.0 | Using index condition | **1ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 995887 | 10.0 | Using where; Using filesort | **364ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 995887 | 10.0 | Using where; Using filesort | **535ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_deleted_like | idx_brand_deleted_like | 414398 | 100.0 | Using where; Using index | **27ms** | +| 8 | COUNT(전체) | index | idx_brand_deleted_like | idx_brand_deleted_like | 995887 | 10.0 | Using where; Using index | **149ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 995887 | 10.0 | Using where; Using filesort | **863ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 414398 | 100.0 | Using index condition | **49ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 전체 커버링 인덱스 세트 + +> 모든 조회 패턴에 최적화된 인덱스 조합 (쓰기 비용 증가 트레이드오프) + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_prod_brand_like | brand_id | 1 | 1 | +| idx_prod_brand_like | deleted_at | 2 | 1 | +| idx_prod_brand_like | like_count | 3 | 1 | +| idx_prod_brand_price | brand_id | 1 | 1 | +| idx_prod_brand_price | deleted_at | 2 | 1 | +| idx_prod_brand_price | price | 3 | 1 | +| idx_prod_brand_created | brand_id | 1 | 1 | +| idx_prod_brand_created | deleted_at | 2 | 1 | +| idx_prod_brand_created | created_at | 3 | 1 | +| idx_prod_deleted_like | deleted_at | 1 | 1 | +| idx_prod_deleted_like | like_count | 2 | 1 | +| idx_prod_deleted_price | deleted_at | 1 | 1 | +| idx_prod_deleted_price | price | 2 | 1 | + +```sql +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 406656 | 100.0 | Using index condition | **0ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_price | 406656 | 100.0 | Using index condition | **1ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 406656 | 100.0 | Using index condition | **0ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 3796 | 100.0 | Using index condition | **0ms** | +| 5 | 전체 + 좋아요순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 497576 | 100.0 | Using index condition | **0ms** | +| 6 | 전체 + 가격순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_price | 497576 | 100.0 | Using index condition | **0ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 406656 | 100.0 | Using where; Using index | **30ms** | +| 8 | COUNT(전체) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_deleted_like | 497576 | 100.0 | Using where; Using index | **119ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 497576 | 100.0 | Using index condition | **90ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 406656 | 100.0 | Using index condition | **43ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 📈 전략별 성능 비교 요약 + +| 쿼리 | 인덱스 없음 (Baseline) | 단일 인덱스: (brand_id) | 복합 인덱스: (brand_id, deleted_at, like_count DESC) | 전체 커버링 인덱스 세트 | +|---|---|---|---|---| +| 브랜드(인기) + 좋아요순 | 332ms | 1727ms | 1ms | 0ms | +| 브랜드(인기) + 가격순 | 347ms | 1711ms | 1218ms | 1ms | +| 브랜드(인기) + 최신순 | 333ms | 1902ms | 1269ms | 0ms | +| 브랜드(중간) + 좋아요순 | 302ms | 22ms | 1ms | 0ms | +| 전체 + 좋아요순 | 351ms | 366ms | 364ms | 0ms | +| 전체 + 가격순 | 342ms | 375ms | 535ms | 0ms | +| COUNT(브랜드 인기) | 182ms | 1939ms | 27ms | 30ms | +| COUNT(전체) | 156ms | 181ms | 149ms | 119ms | +| 딥페이징: 좋아요순 OFFSET 10000 | 620ms | 606ms | 863ms | 90ms | +| 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | 394ms | 1678ms | 49ms | 43ms | diff --git a/.docs/index-benchmark-200k.md b/.docs/index-benchmark-200k.md new file mode 100644 index 000000000..ab0eda3f4 --- /dev/null +++ b/.docs/index-benchmark-200k.md @@ -0,0 +1,415 @@ +# 📊 상품 조회 인덱스 벤치마크 + +> 실행일시: 2026-03-09 23:41:10 +> MySQL 8.0 (TestContainers) + +## 📋 테스트 환경 + +| 항목 | 값 | +|---|---| +| 데이터 수 | 200,000건 | +| 브랜드 수 | 100개 (Zipf 분포) | +| 좋아요 분포 | Log-normal (중앙값 ~12, 롱테일) | +| 측정 횟수 | 3회 평균 | +| 인기 브랜드 ID | 1 (38,323건) | +| 중간 브랜드 ID | 52 (760건) | + +### 브랜드별 상품 수 (상위 10개) + +| 순위 | 브랜드 ID | 상품 수 | 비율 | +|---|---|---|---| +| 1 | 1 | 38,323 | 19.2% | +| 2 | 2 | 19,128 | 9.6% | +| 3 | 3 | 13,030 | 6.5% | +| 4 | 4 | 9,593 | 4.8% | +| 5 | 5 | 7,775 | 3.9% | +| 6 | 6 | 6,518 | 3.3% | +| 7 | 7 | 5,314 | 2.7% | +| 8 | 8 | 4,917 | 2.5% | +| 9 | 9 | 4,250 | 2.1% | +| 10 | 10 | 3,819 | 1.9% | + +### 좋아요 수 분포 + +| 구간 | 상품 수 | 비율 | +|---|---|---| +| 0~10 | 96,024 | 48.0% | +| 11~50 | 56,596 | 28.3% | +| 51~200 | 31,348 | 15.7% | +| 201~1,000 | 13,354 | 6.7% | +| 1,001~5,000 | 2,425 | 1.2% | +| 5,001+ | 253 | 0.1% | + +--- + +## 🔍 인덱스 없음 (Baseline) + +> PK(id)만 존재하는 상태 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ALL | NULL | NULL | 199329 | 1.0 | Using where; Using filesort | **59ms** | +| 2 | 브랜드(인기) + 가격순 | ALL | NULL | NULL | 199329 | 1.0 | Using where; Using filesort | **60ms** | +| 3 | 브랜드(인기) + 최신순 | ALL | NULL | NULL | 199329 | 1.0 | Using where; Using filesort | **65ms** | +| 4 | 브랜드(중간) + 좋아요순 | ALL | NULL | NULL | 199329 | 1.0 | Using where; Using filesort | **61ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **65ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **65ms** | +| 7 | COUNT(브랜드 인기) | ALL | NULL | NULL | 199329 | 1.0 | Using where | **31ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 199329 | 10.0 | Using where | **28ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **104ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ALL | NULL | NULL | 199329 | 1.0 | Using where; Using filesort | **64ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 52 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 단일 인덱스: (brand_id) + +> 가장 기본적인 필터 컬럼 인덱스 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_id | brand_id | 1 | 1 | + +```sql +CREATE INDEX idx_brand_id ON products(brand_id); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 79594 | 10.0 | Using where; Using filesort | **207ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_id | idx_brand_id | 79594 | 10.0 | Using where; Using filesort | **189ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_id | idx_brand_id | 79594 | 10.0 | Using where; Using filesort | **188ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 760 | 10.0 | Using where; Using filesort | **2ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **68ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **75ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_id | idx_brand_id | 79594 | 10.0 | Using where | **221ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 199329 | 10.0 | Using where | **29ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **131ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_id | idx_brand_id | 79594 | 10.0 | Using where; Using filesort | **205ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 52 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 복합 인덱스: (brand_id, deleted_at, like_count DESC) + +> 브랜드 필터 + soft delete + 좋아요순 정렬까지 커버 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_deleted_like | brand_id | 1 | 1 | +| idx_brand_deleted_like | deleted_at | 2 | 1 | +| idx_brand_deleted_like | like_count | 3 | 1 | + +```sql +CREATE INDEX idx_brand_deleted_like ON products(brand_id, deleted_at, like_count DESC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 78640 | 100.0 | Using index condition | **1ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 78640 | 100.0 | Using index condition; Using filesort | **160ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 78640 | 100.0 | Using index condition; Using filesort | **160ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 760 | 100.0 | Using index condition | **1ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **74ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **73ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_deleted_like | idx_brand_deleted_like | 78640 | 100.0 | Using where; Using index | **7ms** | +| 8 | COUNT(전체) | index | idx_brand_deleted_like | idx_brand_deleted_like | 199329 | 10.0 | Using where; Using index | **29ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 199329 | 10.0 | Using where; Using filesort | **130ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 78640 | 100.0 | Using index condition | **26ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 52 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 전체 커버링 인덱스 세트 + +> 모든 조회 패턴에 최적화된 인덱스 조합 (쓰기 비용 증가 트레이드오프) + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_prod_brand_like | brand_id | 1 | 1 | +| idx_prod_brand_like | deleted_at | 2 | 1 | +| idx_prod_brand_like | like_count | 3 | 1 | +| idx_prod_brand_price | brand_id | 1 | 1 | +| idx_prod_brand_price | deleted_at | 2 | 1 | +| idx_prod_brand_price | price | 3 | 1 | +| idx_prod_brand_created | brand_id | 1 | 1 | +| idx_prod_brand_created | deleted_at | 2 | 1 | +| idx_prod_brand_created | created_at | 3 | 1 | +| idx_prod_deleted_like | deleted_at | 1 | 1 | +| idx_prod_deleted_like | like_count | 2 | 1 | +| idx_prod_deleted_price | deleted_at | 1 | 1 | +| idx_prod_deleted_price | price | 2 | 1 | + +```sql +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 75798 | 100.0 | Using index condition | **1ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_price | 75798 | 100.0 | Using index condition | **1ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 75798 | 100.0 | Using index condition | **1ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 760 | 100.0 | Using index condition | **2ms** | +| 5 | 전체 + 좋아요순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 97156 | 100.0 | Using index condition | **2ms** | +| 6 | 전체 + 가격순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_price | 97156 | 100.0 | Using index condition | **2ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 75798 | 100.0 | Using where; Using index | **10ms** | +| 8 | COUNT(전체) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_deleted_like | 97156 | 100.0 | Using where; Using index | **30ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 97156 | 100.0 | Using index condition | **61ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 75798 | 100.0 | Using index condition | **30ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 52 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 📈 전략별 성능 비교 요약 + +| 쿼리 | 인덱스 없음 (Baseline) | 단일 인덱스: (brand_id) | 복합 인덱스: (brand_id, deleted_at, like_count DESC) | 전체 커버링 인덱스 세트 | +|---|---|---|---|---| +| 브랜드(인기) + 좋아요순 | 59ms | 207ms | 1ms | 1ms | +| 브랜드(인기) + 가격순 | 60ms | 189ms | 160ms | 1ms | +| 브랜드(인기) + 최신순 | 65ms | 188ms | 160ms | 1ms | +| 브랜드(중간) + 좋아요순 | 61ms | 2ms | 1ms | 2ms | +| 전체 + 좋아요순 | 65ms | 68ms | 74ms | 2ms | +| 전체 + 가격순 | 65ms | 75ms | 73ms | 2ms | +| COUNT(브랜드 인기) | 31ms | 221ms | 7ms | 10ms | +| COUNT(전체) | 28ms | 29ms | 29ms | 30ms | +| 딥페이징: 좋아요순 OFFSET 10000 | 104ms | 131ms | 130ms | 61ms | +| 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | 64ms | 205ms | 26ms | 30ms | diff --git a/.docs/index-benchmark-500k.md b/.docs/index-benchmark-500k.md new file mode 100644 index 000000000..b23cc3685 --- /dev/null +++ b/.docs/index-benchmark-500k.md @@ -0,0 +1,415 @@ +# 📊 상품 조회 인덱스 벤치마크 + +> 실행일시: 2026-03-09 23:42:17 +> MySQL 8.0 (TestContainers) + +## 📋 테스트 환경 + +| 항목 | 값 | +|---|---| +| 데이터 수 | 500,000건 | +| 브랜드 수 | 100개 (Zipf 분포) | +| 좋아요 분포 | Log-normal (중앙값 ~12, 롱테일) | +| 측정 횟수 | 3회 평균 | +| 인기 브랜드 ID | 1 (95,920건) | +| 중간 브랜드 ID | 51 (1,900건) | + +### 브랜드별 상품 수 (상위 10개) + +| 순위 | 브랜드 ID | 상품 수 | 비율 | +|---|---|---|---| +| 1 | 1 | 95,920 | 19.2% | +| 2 | 2 | 48,095 | 9.6% | +| 3 | 3 | 32,227 | 6.4% | +| 4 | 4 | 24,068 | 4.8% | +| 5 | 5 | 19,347 | 3.9% | +| 6 | 6 | 16,055 | 3.2% | +| 7 | 7 | 13,564 | 2.7% | +| 8 | 8 | 12,114 | 2.4% | +| 9 | 9 | 10,675 | 2.1% | +| 10 | 10 | 9,668 | 1.9% | + +### 좋아요 수 분포 + +| 구간 | 상품 수 | 비율 | +|---|---|---| +| 0~10 | 239,727 | 47.9% | +| 11~50 | 141,866 | 28.4% | +| 51~200 | 78,215 | 15.6% | +| 201~1,000 | 33,396 | 6.7% | +| 1,001~5,000 | 6,131 | 1.2% | +| 5,001+ | 665 | 0.1% | + +--- + +## 🔍 인덱스 없음 (Baseline) + +> PK(id)만 존재하는 상태 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ALL | NULL | NULL | 497598 | 1.0 | Using where; Using filesort | **178ms** | +| 2 | 브랜드(인기) + 가격순 | ALL | NULL | NULL | 497598 | 1.0 | Using where; Using filesort | **157ms** | +| 3 | 브랜드(인기) + 최신순 | ALL | NULL | NULL | 497598 | 1.0 | Using where; Using filesort | **155ms** | +| 4 | 브랜드(중간) + 좋아요순 | ALL | NULL | NULL | 497598 | 1.0 | Using where; Using filesort | **149ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **172ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **192ms** | +| 7 | COUNT(브랜드 인기) | ALL | NULL | NULL | 497598 | 1.0 | Using where | **143ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 497598 | 10.0 | Using where | **95ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **442ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ALL | NULL | NULL | 497598 | 1.0 | Using where; Using filesort | **267ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 단일 인덱스: (brand_id) + +> 가장 기본적인 필터 컬럼 인덱스 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_id | brand_id | 1 | 1 | + +```sql +CREATE INDEX idx_brand_id ON products(brand_id); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 191116 | 10.0 | Using where; Using filesort | **831ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_id | idx_brand_id | 191116 | 10.0 | Using where; Using filesort | **773ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_id | idx_brand_id | 191116 | 10.0 | Using where; Using filesort | **696ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_id | idx_brand_id | 1900 | 10.0 | Using where; Using filesort | **12ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 497965 | 10.0 | Using where; Using filesort | **180ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 497965 | 10.0 | Using where; Using filesort | **175ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_id | idx_brand_id | 191116 | 10.0 | Using where | **771ms** | +| 8 | COUNT(전체) | ALL | NULL | NULL | 497965 | 10.0 | Using where | **75ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 497965 | 10.0 | Using where; Using filesort | **373ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_id | idx_brand_id | 191116 | 10.0 | Using where; Using filesort | **965ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 복합 인덱스: (brand_id, deleted_at, like_count DESC) + +> 브랜드 필터 + soft delete + 좋아요순 정렬까지 커버 + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_brand_deleted_like | brand_id | 1 | 1 | +| idx_brand_deleted_like | deleted_at | 2 | 1 | +| idx_brand_deleted_like | like_count | 3 | 1 | + +```sql +CREATE INDEX idx_brand_deleted_like ON products(brand_id, deleted_at, like_count DESC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 199050 | 100.0 | Using index condition | **2ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 199050 | 100.0 | Using index condition; Using filesort | **609ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 199050 | 100.0 | Using index condition; Using filesort | **623ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 1900 | 100.0 | Using index condition | **1ms** | +| 5 | 전체 + 좋아요순 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **177ms** | +| 6 | 전체 + 가격순 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **171ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_brand_deleted_like | idx_brand_deleted_like | 199050 | 100.0 | Using where; Using index | **14ms** | +| 8 | COUNT(전체) | index | idx_brand_deleted_like | idx_brand_deleted_like | 497598 | 10.0 | Using where; Using index | **53ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ALL | NULL | NULL | 497598 | 10.0 | Using where; Using filesort | **500ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_brand_deleted_like | idx_brand_deleted_like | 199050 | 100.0 | Using index condition | **40ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 🔍 전체 커버링 인덱스 세트 + +> 모든 조회 패턴에 최적화된 인덱스 조합 (쓰기 비용 증가 트레이드오프) + +**적용된 인덱스:** + +| Key_name | Column_name | Seq | Non_unique | +|---|---|---|---| +| PRIMARY | id | 1 | 0 | +| idx_prod_brand_like | brand_id | 1 | 1 | +| idx_prod_brand_like | deleted_at | 2 | 1 | +| idx_prod_brand_like | like_count | 3 | 1 | +| idx_prod_brand_price | brand_id | 1 | 1 | +| idx_prod_brand_price | deleted_at | 2 | 1 | +| idx_prod_brand_price | price | 3 | 1 | +| idx_prod_brand_created | brand_id | 1 | 1 | +| idx_prod_brand_created | deleted_at | 2 | 1 | +| idx_prod_brand_created | created_at | 3 | 1 | +| idx_prod_deleted_like | deleted_at | 1 | 1 | +| idx_prod_deleted_like | like_count | 2 | 1 | +| idx_prod_deleted_price | deleted_at | 1 | 1 | +| idx_prod_deleted_price | price | 2 | 1 | + +```sql +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC); +``` + +### 벤치마크 결과 + +| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 | +|---|---|---|---|---|---|---|---|---| +| 1 | 브랜드(인기) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 196148 | 100.0 | Using index condition | **1ms** | +| 2 | 브랜드(인기) + 가격순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_price | 196148 | 100.0 | Using index condition | **1ms** | +| 3 | 브랜드(인기) + 최신순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 196148 | 100.0 | Using index condition | **2ms** | +| 4 | 브랜드(중간) + 좋아요순 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 1900 | 100.0 | Using index condition | **1ms** | +| 5 | 전체 + 좋아요순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 248799 | 100.0 | Using index condition | **0ms** | +| 6 | 전체 + 가격순 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_price | 248799 | 100.0 | Using index condition | **1ms** | +| 7 | COUNT(브랜드 인기) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_created | 196148 | 100.0 | Using where; Using index | **24ms** | +| 8 | COUNT(전체) | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_deleted_like | 248799 | 100.0 | Using where; Using index | **112ms** | +| 9 | 딥페이징: 좋아요순 OFFSET 10000 | ref | idx_prod_deleted_like,idx_prod... | idx_prod_deleted_like | 248799 | 100.0 | Using index condition | **241ms** | +| 10 | 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | ref | idx_prod_brand_like,idx_prod_b... | idx_prod_brand_like | 196148 | 100.0 | Using index condition | **75ms** | + +
실행된 쿼리 상세 + +**1. 브랜드(인기) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**2. 브랜드(인기) + 가격순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**3. 브랜드(인기) + 최신순** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 +``` + +**4. 브랜드(중간) + 좋아요순** +```sql +SELECT * FROM products WHERE brand_id = 51 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**5. 전체 + 좋아요순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +**6. 전체 + 가격순** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +**7. COUNT(브랜드 인기)** +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +**8. COUNT(전체)** +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL +``` + +**9. 딥페이징: 좋아요순 OFFSET 10000** +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +**10. 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000** +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000 +``` + +
+ +--- + +## 📈 전략별 성능 비교 요약 + +| 쿼리 | 인덱스 없음 (Baseline) | 단일 인덱스: (brand_id) | 복합 인덱스: (brand_id, deleted_at, like_count DESC) | 전체 커버링 인덱스 세트 | +|---|---|---|---|---| +| 브랜드(인기) + 좋아요순 | 178ms | 831ms | 2ms | 1ms | +| 브랜드(인기) + 가격순 | 157ms | 773ms | 609ms | 1ms | +| 브랜드(인기) + 최신순 | 155ms | 696ms | 623ms | 2ms | +| 브랜드(중간) + 좋아요순 | 149ms | 12ms | 1ms | 1ms | +| 전체 + 좋아요순 | 172ms | 180ms | 177ms | 0ms | +| 전체 + 가격순 | 192ms | 175ms | 171ms | 1ms | +| COUNT(브랜드 인기) | 143ms | 771ms | 14ms | 24ms | +| COUNT(전체) | 95ms | 75ms | 53ms | 112ms | +| 딥페이징: 좋아요순 OFFSET 10000 | 442ms | 373ms | 500ms | 241ms | +| 딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000 | 267ms | 965ms | 40ms | 75ms | diff --git a/.docs/index-optimization-report.md b/.docs/index-optimization-report.md new file mode 100644 index 000000000..06f104271 --- /dev/null +++ b/.docs/index-optimization-report.md @@ -0,0 +1,266 @@ +# 상품 목록 조회 인덱스 최적화 보고서 + +## 1. 목표 + +상품 목록 조회 API의 성능을 분석하고, 인덱스 전략별 성능 차이를 실측하여 최적의 인덱스를 선정한다. + +- 데이터량: 10만 / 20만 / 50만 / 100만건 +- 대상 쿼리: 브랜드 필터 + 정렬(좋아요순, 가격순, 최신순) 조합 +- 환경: MySQL 8.0 (TestContainers) + +--- + +## 2. 테스트 설계 과정 + +### 2.1 데이터 시딩 방식 + +JPA `save()`를 반복하면 엔티티 라이프사이클(@PrePersist, 변경감지 등) 오버헤드가 크다. +JDBC PreparedStatement batch insert를 선택했다. 5,000건 단위로 `executeBatch()`하여 100만건도 약 30초 내에 삽입 가능했다. + +### 2.2 데이터 분포 설계 + +현실 세계의 커머스 데이터를 반영하기 위해 균등 분포 대신 편향된 분포를 적용했다. + +**브랜드 분포: Zipf 분포 (100개 브랜드)** + +현실에서는 인기 브랜드에 상품이 집중된다. Zipf 분포(`P(brand_i) ∝ 1/i`)를 적용하여 상위 브랜드에 상품이 쏠리도록 했다. + +실제 결과 (시드 고정, 10만건 기준): +| 순위 | 브랜드 ID | 상품 수 | 비율 | +|---|---|---|---| +| 1 | 1 | 19,045 | 19.0% | +| 2 | 2 | 9,549 | 9.5% | +| 3 | 3 | 6,568 | 6.6% | +| ... | ... | ... | ... | +| 50 | 49 | 384 | 0.4% | + +→ 상위 10개 브랜드가 전체의 약 56%를 차지. 인기 브랜드 필터 시 대량 데이터가 걸리는 현실적 시나리오를 재현했다. + +**좋아요 수 분포: Log-normal 분포** + +소셜 engagement는 "대부분 적고, 소수만 많은" 롱테일 패턴을 따른다. +`Math.exp(random.nextGaussian() * 2.0 + 2.5)`로 생성하여: + +| 구간 | 비율 | +|---|---| +| 0~10 | 48.1% | +| 11~50 | 28.2% | +| 51~200 | 15.6% | +| 201~1,000 | 6.8% | +| 1,001~5,000 | 1.2% | +| 5,001+ | 0.1% | + +→ 절반 가까이가 좋아요 10개 이하이고, 1% 미만만 1,000개 이상. 실제 커머스 플랫폼의 좋아요 분포와 유사하다. + +**가격 분포** + +실제 커머스 가격대를 반영하여 13개 기준가(5,000 ~ 499,000원)에서 랜덤 선택 후 ±20% 노이즈를 적용했다. + +### 2.3 벤치마크 쿼리 선정 + +현재 API가 지원하는 실제 쿼리 패턴을 그대로 테스트 대상으로 삼았다: + +```java +// ProductSortType: LATEST, PRICE_ASC, LIKES_DESC +// brandId: optional +Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); +Page findAllByDeletedAtIsNull(Pageable pageable); +``` + +이로부터 도출된 10개 벤치마크 쿼리: + +| # | 쿼리 | 근거 | +|---|---|---| +| 1~3 | 브랜드(인기) + 좋아요순/가격순/최신순 | 인기 브랜드 필터(19% 데이터) + 3가지 정렬 | +| 4 | 브랜드(중간) + 좋아요순 | 소규모 브랜드(0.4%) 대비 성능 차이 확인 | +| 5~6 | 전체 + 좋아요순/가격순 | 브랜드 필터 없는 경우 | +| 7~8 | COUNT 쿼리 | Spring Data Page의 totalElements 산출용 (매 조회마다 실행됨) | +| 9~10 | 딥 페이지네이션 (OFFSET 5000/10000) | OFFSET 기반 페이징의 한계 확인 | + +### 2.4 측정 방식 + +- **ANALYZE TABLE 선행**: 시딩 직후 `ANALYZE TABLE products` 실행하여 통계 정보를 갱신한 뒤 벤치마크 시작. 초기 버전에서 이를 누락하여 Baseline의 EXPLAIN `rows=20`(실제 10만건)이라는 부정확한 결과가 나왔고, 수정 후 `rows=99,642`로 정상화됐다. +- **Warm-up**: 각 쿼리마다 측정 전 1회 실행하여 버퍼 풀에 데이터를 로드한 후 3회 측정 평균을 기록. +- **인덱스 전략 간 독립성**: 전략마다 CREATE INDEX → ANALYZE → 벤치마크 → DROP INDEX 순으로 실행하여 이전 전략의 인덱스가 남지 않도록 했다. + +--- + +## 3. 테스트한 인덱스 전략 + +| 전략 | 인덱스 | 의도 | +|---|---|---| +| 0. Baseline | PK만 | 비교 기준 | +| 1. 단일 | `(brand_id)` | 가장 직관적인 필터 컬럼 인덱스 | +| 2. 복합 1개 | `(brand_id, deleted_at, like_count DESC)` | 가장 핵심 쿼리(브랜드+좋아요순) 최적화 | +| 3. 전체 커버링 | 아래 5개 | 모든 쿼리 패턴 최적화 | + +전체 커버링 세트: +```sql +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC); +``` + +복합 인덱스 설계 근거: +- 컬럼 순서: `(등치 조건) → (등치 조건) → (정렬 컬럼)` 순으로 배치 +- `deleted_at IS NULL`은 MySQL 8.0에서 등치 조건(ref)으로 처리되므로 인덱스 정렬까지 활용 가능 +- 브랜드 필터 쿼리용 `(brand_id, deleted_at, ...)` + 전체 조회용 `(deleted_at, ...)` 분리 + +--- + +## 4. 결과 요약 + +> 개별 데이터량별 상세 결과는 `index-benchmark-100k.md`, `index-benchmark-200k.md`, `index-benchmark-500k.md`, `index-benchmark-1m.md` 참조. + +### 4.1 핵심 쿼리: 브랜드(인기) + 좋아요순 + +| 데이터량 | Baseline | 단일(brand_id) | 복합 1개 | 전체 커버링 | +|---|---|---|---|---| +| 10만 | 28ms | 18ms | **0ms** | **0ms** | +| 20만 | 59ms | 207ms | **1ms** | **1ms** | +| 50만 | 178ms | 831ms | **2ms** | **1ms** | +| 100만 | 332ms | 1,727ms | **1ms** | **0ms** | + +### 4.2 브랜드(인기) + 가격순 (복합 인덱스가 커버하지 못하는 쿼리) + +| 데이터량 | Baseline | 단일(brand_id) | 복합 1개 | 전체 커버링 | +|---|---|---|---|---| +| 10만 | 28ms | 18ms | 22ms | **1ms** | +| 20만 | 60ms | 189ms | 160ms | **1ms** | +| 50만 | 157ms | 773ms | 609ms | **1ms** | +| 100만 | 347ms | 1,711ms | 1,218ms | **1ms** | + +### 4.3 전체 + 좋아요순 (브랜드 필터 없음) + +| 데이터량 | Baseline | 단일(brand_id) | 복합 1개 | 전체 커버링 | +|---|---|---|---|---| +| 10만 | 32ms | 31ms | 31ms | **1ms** | +| 20만 | 65ms | 68ms | 74ms | **2ms** | +| 50만 | 172ms | 180ms | 177ms | **0ms** | +| 100만 | 351ms | 366ms | 364ms | **0ms** | + +### 4.4 딥 페이지네이션 (전체 + 좋아요순 OFFSET 10000) + +| 데이터량 | Baseline | 단일(brand_id) | 복합 1개 | 전체 커버링 | +|---|---|---|---|---| +| 10만 | 56ms | 54ms | 55ms | 28ms | +| 20만 | 104ms | 131ms | 130ms | 61ms | +| 50만 | 442ms | 373ms | 500ms | 241ms | +| 100만 | 620ms | 606ms | 863ms | **90ms** | + +--- + +## 5. 분석 + +### 5.1 단일 인덱스의 함정 + +`(brand_id)` 단일 인덱스는 데이터가 늘어날수록 **Baseline보다 느려졌다**. + +원인: 옵티마이저가 인덱스를 사용해 `brand_id = 1` 조건으로 19만건을 필터링한 뒤, 이 19만건에 대해 filesort를 수행한다. Full Scan(100만건 순차 읽기)보다 "인덱스 랜덤 I/O + 대량 filesort"가 더 비싼 것이다. + +교훈: **필터만 되고 정렬을 커버하지 못하는 인덱스는 대량 데이터에서 역효과**를 낼 수 있다. + +### 5.2 복합 인덱스 1개의 한계 + +`(brand_id, deleted_at, like_count DESC)`는 좋아요순 쿼리에서 극적인 효과를 보였지만, 가격순/최신순에서는 단일 인덱스와 같은 문제(인덱스 필터 후 filesort)가 발생했다. + +EXPLAIN 비교 (100만건, 브랜드(인기) + 가격순): +- 복합 인덱스: `type=ref, key=idx_brand_deleted_like, Extra=Using index condition; Using filesort` → **1,218ms** +- 전체 커버링: `type=ref, key=idx_prod_brand_price, Extra=Using index condition` → **1ms** + +### 5.3 전체 커버링 세트의 효과 + +모든 쿼리에서 데이터량에 관계없이 0~2ms를 유지했다. EXPLAIN에서 모든 쿼리가 `Using index condition` (filesort 없음)으로 처리되었다. + +### 5.4 딥 페이지네이션의 구조적 한계 + +커버링 인덱스로도 100만건 OFFSET 10000에서 90ms가 소요되었다. OFFSET 기반 페이징은 "건너뛸 행을 모두 읽고 버리는" 방식이므로 인덱스만으로는 근본적 해결이 어렵다. 향후 cursor-based pagination(keyset pagination) 등 구조적 대안을 고려할 수 있다. + +### 5.5 `deleted_at IS NULL`과 인덱스 + +MySQL 8.0에서 `IS NULL`은 등치 조건(ref access type)으로 처리된다. 따라서 `(brand_id, deleted_at, like_count)` 인덱스에서 `WHERE brand_id = ? AND deleted_at IS NULL ORDER BY like_count DESC` 쿼리가 인덱스 정렬까지 활용 가능하다. 이는 `IS NULL`이 range 조건으로 처리되던 이전 MySQL 버전과의 중요한 차이점이다. + +--- + +## 6. 인덱스 선정 + +### 6.1 벤치마크 결과만 보면 + +전체 커버링 인덱스 세트(5개)가 모든 쿼리에서 0~2ms를 달성하여 성능 측면에서는 최적이다. 하지만 "인덱스 5개를 그대로 적용한다"가 현실의 최선인지는 별개의 문제다. + +### 6.2 현실 세계의 비즈니스 특성 고려 + +**쿼리별 실제 트래픽 비중 분석** + +모든 쿼리가 동일한 빈도로 호출되지 않는다. 실제 커머스 UX를 기준으로: + +| 쿼리 패턴 | UX 시나리오 | 트래픽 비중 | +|---|---|---| +| 브랜드 + 좋아요순 | 브랜드 페이지 "인기순" | 높음 | +| 브랜드 + 가격순 | 브랜드 페이지 "가격순" | 높음 | +| 브랜드 + 최신순 | 브랜드 페이지 "최신순" | 높음 | +| 전체 + 좋아요순 | 홈페이지 "인기 상품" | 높음 | +| 전체 + 가격순 | 필터 없이 가격순 | **낮음** — 브랜드/카테고리 없이 가격만으로 정렬하는 UX는 드뭄 | +| 전체 + 최신순 | 홈페이지 기본 목록 | 높음, 그러나 캐시 1순위 대상 | + +브랜드 필터가 있는 쿼리 3종은 조합이 많아(100개 브랜드 × 3정렬 × 페이지) 캐시 효율이 낮다. 반면 전체 조회는 조합이 적어(3정렬 × 페이지) 캐시로 커버하기 좋다. + +**인덱스와 캐시의 역할 분담** + +이번 과제에는 Redis 캐시 적용도 포함되어 있다. 두 기술을 함께 고려하면: + +- **인덱스가 필수인 영역**: 브랜드 필터 조회 — 조합이 많아 캐시만으로는 비효율적 +- **캐시로 위임 가능한 영역**: 전체 조회 — 조합이 적고, 홈페이지 등 반복 요청이 많아 캐시 적중률이 높음 +- **양쪽 모두 필요한 영역**: 전체 + 좋아요순 — 홈페이지 인기상품은 캐시 대상이지만, 캐시 miss/갱신 시 원본 쿼리도 빨라야 함 + +**쓰기 비용 평가** + +| 쓰기 작업 | 빈도 | 영향 | +|---|---|---| +| 상품 등록 | 하루 수십건 (관리자) | 모든 인덱스 | +| 가격 변경 | 하루 수건 (관리자) | price 인덱스만 | +| like_count 증감 | 하루 수천~수만건 (사용자) | like_count 포함 인덱스 2개 | +| soft delete | 매우 드뭄 | deleted_at 인덱스 | + +products 테이블은 근본적으로 read-heavy다. 가장 빈번한 쓰기인 like_count 증감도 영향받는 인덱스는 2개뿐이며, 인덱스가 4개든 5개든 쓰기 비용 차이는 미미하다. + +### 6.3 의사결정: 인덱스 4개 + 캐시 보완 + +**채택한 인덱스 (4개):** + +```sql +-- 브랜드 필터 + 정렬 (3개) — 인덱스가 필수인 영역 +CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC); +CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC); +CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC); + +-- 전체 조회 + 인기순 (1개) — 캐시 miss/갱신 시에도 빠르게 +CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC); +``` + +**버린 인덱스와 근거:** + +| 버린 인덱스 | 커버하는 쿼리 | 버린 이유 | +|---|---|---| +| `(deleted_at, price ASC)` | 전체 + 가격순 | 필터 없이 가격순 정렬은 실제 UX에서 드뭄. 발생해도 캐시로 커버 | +| `(deleted_at, created_at DESC)` | 전체 + 최신순 | 캐시 대상 1순위 (홈페이지 기본). 캐시 miss 시 100만건 332ms는 수용 가능 | + +**타협점:** + +- "전체 + 가격순"과 "전체 + 최신순"은 인덱스 없이 100만건 기준 332~342ms가 소요된다. 이는 사용자 체감에 영향을 줄 수 있는 수치이지만, 캐시가 적용되면 실제 DB 호출 빈도가 크게 줄어든다. TTL 내 캐시 히트 시 <1ms로 응답 가능하다. +- 만약 캐시 없이 운영하거나, 캐시 miss 빈도가 예상보다 높다면 `(deleted_at, price ASC)`를 추가하여 5개로 확장하면 된다. 인덱스 추가는 `ALTER TABLE`로 언제든 가능하고, products 테이블의 쓰기 특성상 부담이 적다. + +**예상 성능 (100만건):** + +| 쿼리 | 인덱스 | 캐시 | 최종 응답 | +|---|---|---|---| +| 브랜드 + 좋아요순 | 0~1ms | - | **0~1ms** | +| 브랜드 + 가격순 | 1ms | - | **1ms** | +| 브랜드 + 최신순 | 0~1ms | - | **0~1ms** | +| 전체 + 좋아요순 | 0~1ms | 히트 시 <1ms | **<1ms** | +| 전체 + 가격순 | 342ms (미커버) | 히트 시 <1ms | **<1ms** (캐시) | +| 전체 + 최신순 | 332ms (미커버) | 히트 시 <1ms | **<1ms** (캐시) | + +인덱스 4개로 전체 커버링(5개)의 95% 효과를 달성하면서, 나머지 5%는 캐시가 보완하는 구조다. diff --git a/.docs/practical_read_opt.md b/.docs/practical_read_opt.md new file mode 100644 index 000000000..0b9fed8a9 --- /dev/null +++ b/.docs/practical_read_opt.md @@ -0,0 +1,419 @@ + + +- 상품 목록 조회가 느릴 때, 병목의 원인을 파악하고 실제 성능을 개선하는 구조를 학습합니다. +- 인덱스와 캐시를 중심으로, 읽기 병목 문제를 추적하고 실전 방식으로 해결해봅니다. +- 단순한 속도 개선이 아닌, **구조적 개선과 유지보수 가능한 설계**를 함께 고민합니다. +- 조회 성능 향상을 위한 캐시 설계, TTL 설정, 무효화 전략의 실전 감각을 익힙니다. + + + +- 인덱스 설계 +- Redis 캐시 +- 조회 병목 분석 +- 정렬 및 필터 최적화 + + + +## 🚧 실무에서 겪는 읽기 성능 문제들 + +> 💬 트래픽이 많아지면 **쓰기보다 읽기가 문제다**는 말이 있을 정도로 읽기 병목은 자주 발생합니다. +> +> +> 서비스의 이용자가 많아질수록 **읽기 연산은 쓰기 연산의 수 배~수십 배로 증가**하고 이는 곧 **사용자 경험(UX)과 직결되는 성능 이슈**로 이어집니다. +> + +--- + +## 🔎 인덱스 설계와 조회 최적화 + +### 🚧 실무에서 겪는 문제 + +- 상품 목록을 `brandId`로 필터하고, `price_asc`로 정렬하는데 너무 느리다 +- `likes_desc` 정렬 시 성능이 급락하거나, 인덱스를 써도 **효과가 없음** +- 페이지네이션이 붙으면 `OFFSET` 처리로 지연이 누적됨 +- 인덱스가 있어도 **조건 순서가 맞지 않으면 전혀 사용되지 않음** + +### 🧱 Index On RDB + +> 이번 주제에서 말하는 인덱스는 **MySQL, PostgreSQL 등 RDB에서 사용하는 인덱스**입니다. 이 인덱스는 대부분 **B-Tree 기반**으로 동작하며, **WHERE 절**, **정렬**, **JOIN** 등에 사용되어 성능을 극적으로 개선할 수 있습니다. + +📚 전체 테이블을 일일이 탐색하지 않고, 정렬된 값의 **책갈피**를 이용해 필요한 데이터에 빠르게 접근하는 방식이라고 생각해보면 쉽습니다. +> + + + +### 🧠 단일 vs 복합 인덱스 + +**✅ 단일 인덱스** + +```sql +CREATE INDEX idx_brand_id ON products(brand_id); +``` + +- brandId 필터엔 빠름, 정렬(price)까지 커버하진 못함 + +**✅ 복합 인덱스** + +```sql +CREATE INDEX idx_brand_price ON products(brand_id, price); +``` + +- brandId + price 정렬까지 함께 커버 가능 +- 조건이 “왼쪽 → 오른쪽 순서”로 사용될 때만 효과 있음 + +| 인덱스 | WHERE brandId | ORDER BY price | +| --- | --- | --- | +| (brand_id) | ✅ | ❌ | +| (brand_id, price) | ✅ | ✅ | +| (price, brand_id) | ❌ | ❌ | + +### 🧪 EXPLAIN - 쿼리 실행 계획 확인 + +```sql +EXPLAIN SELECT * FROM products +WHERE brand_id = 1 +ORDER BY price ASC +LIMIT 20; +``` + +| 항목 | 의미 | 확인 포인트 | +| --- | --- | --- | +| key | 사용된 인덱스 이름 | null이면 인덱스 미사용 | +| type | 접근 방식 | index / range / ALL | +| rows | 예측된 스캔 행 수 | 낮을수록 좋음 | +| Extra | 추가 정보 | Using index / Using filesort 여부 | + +> `Using filesort` 가 Extra 에 표시된다면 인덱스 정렬이 적용되지 않고, 정렬 연산이 추가 수행됨 +> + +### 🔄 실전 예시 비교 + +### ❌ 인덱스 없을 때 + +```sql +SELECT * FROM products +WHERE brand_id = 1 +ORDER BY price ASC; +``` + +- rows: 10,000 +- Extra: Using filesort + +### ✅ 인덱스 추가 후 + +```sql +CREATE INDEX idx_brand_price ON products(brand_id, price); +``` + +```sql +SELECT * FROM products +WHERE brand_id = 1 +ORDER BY price ASC; +``` + +- rows: 200 +- Extra: Using index + +### ⚠ 인덱스 설계 시 주의할 점 + +- 자주 변경되는 컬럼에 인덱스를 남발하면 **쓰기 성능 저하** +- **조건 순서, 필터/정렬 조합**을 고려해 복합 인덱스를 설계해야 함 +- 모수가 작을 땐 오히려 인덱스 없이 **Full Scan이 더 빠를 수도 있음** + + + +### 카디널리티(중복도, 다양성) + +```jsx +Q. 인덱스를 카디널리티가 높은 컬럼에 걸어야할까? 낮은 컬럼에 걸어야할까? +-> 카디널리티가 높은 컬럼에 걸어야한다. + +Q. 성별, 주민등록번호중 어떤게 카디널리티가 높을까? +-> 주민등록번호가 카디널리티가 더 높다. + +Q. Get API를 개발하는데 단건 조회, 유저가 좋아요한 상품중에 Price가 5000원 이상이면서 최근 30일 이내의 여성 상품 +-> like table, Index(user_id, updated_at, price) + +Q. 이름과 생년월일이 있어요. 카디널리티 뭐가 높죠? +-> 카디널리티라는건 내가보면 안다. + +*현재 테이블의 카디널리티 정보를 뽑을수 있음. 글쓰기에 포함되면 좋을것 같아요. +``` + +--- + +## ❤️ 좋아요 수 정렬과 정규화 전략 + +### 🚧 실무에서 겪는 문제 + +- 상품 목록을 **좋아요 수 기준(like_desc)** 으로 정렬하려는데 성능이 매우 느림 +- `like`는 별도 테이블이고, `product` 테이블에 **likeCount**가 없음 +- 쿼리에서 집계(join + group by)하거나, 애플리케이션에서 count 쿼리를 반복 호출 → N+1 발생 +- 상품이 많아질수록 쿼리 복잡도, 네트워크 비용, 정렬 비용이 급격히 증가 + +### 🧱 왜 문제가 발생할까? + +**기본 구조** + +- `product` 테이블 ← 상품 정보 +- `like` 테이블 ← (user_id, product_id) 조합 + +**정렬 쿼리 예시 (집계 방식)** + +```sql +SELECT p.*, COUNT(l.id) AS like_count +FROM product p +LEFT JOIN likes l ON p.id = l.product_id +GROUP BY p.id +ORDER BY like_count DESC +LIMIT 20; +``` + +> 📉 **WARNING!** GROUP BY, 정렬, 조인 비용이 한 번에 들어감 → 인덱스로도 커버 어려움 +> + +### **❗** 대안 1: 의도적 비정규화 - 제품 테이블에 like_count 필드 유지 + +```sql +ALTER TABLE product ADD COLUMN like_count INT DEFAULT 0; +``` + +- 좋아요 등록/취소 시, `product.like_count`도 함께 갱신 +- 정렬 시 단순 정렬로 처리 가능 → 성능 향상 + +```sql +SELECT * FROM product ORDER BY like_count DESC; +``` + +> ✅ **장점:** 매우 빠른 정렬, 인덱스 사용 가능 +> +> +> ⚠ **단점:** 쓰기 시 동시성/정합성 문제 고려 필요 +> + +### ❗ **대안 2: 조회 전용 구조로 분리** + +- 읽기 전용 테이블 또는 View에서 미리 집계한 좋아요 수 제공 +- 좋아요 수를 주기적으로 적재 (예: 이벤트 기반, 배치 등) + +```sql +CREATE TABLE product_like_view ( + product_id BIGINT PRIMARY KEY, + like_count INT +); +``` + +> ✅ **장점:** 조회/튜닝 용이, 정렬도 인덱스로 가능 +> +> +> ⚠ **단점:** 실시간성 일부 희생, 별도 sync 로직 필요 +> + +### 🔍 설계 판단 기준 + +| 항목 | 정규화 유지 (조인 집계) | 비정규화 (likeCount 컬럼) | +| --- | --- | --- | +| 조회 성능 | ❌ 느림 | ✅ 빠름 | +| 쓰기 복잡도 | ✅ 단순 | ⚠ 증가 | +| 실시간성 | ✅ 완전 보장 | ⚠ 약간의 지연 가능 | +| 확장성 | ✅ 유연 | ❌ 단순 정렬만 가능 | + +### 💡 실무 팁 + +- 정렬 기준으로 쓰는 값은 **조회 전용 구조 또는 비정규화 필드**로 따로 유지하는 경우가 많음 +- 이벤트 기반으로 count를 갱신하거나, 일정 주기마다 적재하는 구조로 대응 가능 +- 실시간 정합성보다 UX와 속도가 더 중요한 경우엔 과감히 비정규화를 선택하기도 함 + +--- + +## 🚄 캐시 전략 - 자주 조회되는 데이터를 빠르게 제공하기 + +### 🚧 실무에서 겪는 문제 + +- 인기 상품, 브랜드 목록, 가격 필터 등은 **항상 동일한 요청**이 반복됨 +- 유저 수가 늘어날수록 동일 쿼리 요청도 수십 배로 증가 +- 데이터는 자주 안 바뀌는데, **매번 DB에서 새로 조회** +- 응답 속도 문제뿐 아니라, **DB 부하까지 증가**해 전체 서비스에 영향 + +### 🧱 캐시는 왜 필요한가? + +- **자주 요청되지만 자주 바뀌지 않는 데이터** 를 위한 구조적 최적화 +- 응답 속도를 극적으로 줄이고, DB 요청 횟수를 획기적으로 감소시킴 +- 조회 시점 기준으로 일정 시간 동안 **가장 최근에 봤거나 만들어진 결과** 를 제공 + +> 🧠 캐시는 결국 **정확도 ↔ 속도** 사이에서 균형을 선택하는 전략 +> + +### 🔍 TTL과 캐시 무효화 전략 + +| 전략 | 설명 | 사용 예 | +| --- | --- | --- | +| TTL (Time-To-Live) | 일정 시간 지나면 자동 만료 | 상품 상세 TTL 10분 | +| 수동 무효화 (@CacheEvict) | 특정 이벤트 발생 시 삭제 | 좋아요 눌렀을 때 상품 캐시 삭제 | +| Write-Through | 쓰기 시 캐시도 함께 갱신 | 포인트 충전, 장바구니 등 | +| Read-Through | 조회 시 캐시 없으면 DB 조회 + 캐시 저장 | 기본적인 @Cacheable 동작 방식 | +| Refresh-Ahead | 만료 전에 미리 새로고침 | 랭킹, 홈화면 등 주기 갱신이 필요한 경우 | + +### 🧪 Spring 기반 캐시 활용 예제 + +### **✅ AOP 기반 @Cacheable 방식** + +```java +@Service +public class ProductService { + .. + @Cacheable(cacheNames = "productDetail", key = "#productId") + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(productId)); + } +} +``` + +- 내부적으로 Spring AOP가 프록시를 생성하여 메서드 실행 전/후 캐시를 검사 +- 호출 시 **캐시 Hit이면 메서드 실행 생략**, Miss면 실행 후 결과 저장 +- **장점:** 코드가 간결하고 빠르게 도입 가능 +- **단점:** 흐름이 추상화되어 있어 언제 어떤 타이밍에 캐싱되는지 체감이 어려움 + +### 🔍 직접 RedisTemplate 사용 + +```kotlin +@Service +public class ProductService { + + private final RedisTemplate redisTemplate; + private final ProductRepository productRepository; + + public ProductService(RedisTemplate redisTemplate, + ProductRepository productRepository) { + this.redisTemplate = redisTemplate; + this.productRepository = productRepository; + } + + public Product getProduct(Long productId) { + String key = "product:detail:" + productId; + + // 1. Redis에서 먼저 조회 + Product cached = redisTemplate.opsForValue().get(key); + if (cached != null) { + return cached; + } + + // 2. DB 조회 + Product product = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(productId)); + + // 3. Redis에 캐시 저장 (10분 TTL) + redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10)); + + return product; + } +} +``` + +- **직접 키를 만들고 TTL을 지정**하며 캐시 저장 +- 흐름이 명시적이라 **캐시 동작을 눈으로 확인 가능** +- 실무에서도 복잡한 구조, 커스텀 캐시 처리 시 자주 사용됨 + +### 💡 정리: 언제 어떤 방식이 적절할까? + +| 구분 | @Cacheable | RedisTemplate | +| --- | --- | --- | +| 도입 속도 | ✅ 빠름 | ❌ 설정 복잡 | +| 코드 간결성 | ✅ 매우 간결 | ❌ 직접 처리 필요 | +| 캐시 흐름 이해 | ❌ AOP로 감춰짐 | ✅ 명확히 보임 | +| 복잡한 캐시 구조 | ❌ 어려움 | ✅ 세밀한 제어 가능 | +| 실무 사용 예 | 단순 조회 (상품 상세 등) | TTL 제어, 조건부 캐싱 등 고급 케이스 | + +> 단순한 `@Cacheable` 도 좋지만, +> +> +> 실무에서는 **캐시가 언제 저장되고 언제 무효화되는지**를 정확히 알아야 합니다. +> +> 그래서 이번 과제에서는 **직접 RedisTemplate을 사용해 캐시 흐름을 제어해보는 실습도 추천**합니다. +> + +### 💡 실무 팁 + +- 캐시는 **정확한 데이터를 보장하지 않는다**는 점을 항상 인지해야 함 +- 캐시 키 설계는 중요한 도메인 속성 기준으로 구체적으로 짜야 함 +- 캐시를 적용할 때는 항상 **만약 캐시가 없으면?** 시나리오를 함께 고려 +- 중요한 비즈니스 데이터에는 캐시보다 DB 정합성이 우선됨 + +--- + +## 🌾읽기 전용 구조 - Pre-aggregation + +### 🚧 실무에서 겪는 문제 + +- 좋아요 수 정렬, 랭킹, 통계 페이지 등은 **매번 실시간으로 계산하면 느림** +- 실시간성보다 **빠른 응답**이 중요한 경우가 많음 + +### ✅ Pre-aggregation이란? + +- **데이터를 미리 계산해서 저장해두는 구조** +- 조회 시점에는 가공 없이 바로 반환 가능 + +> e.g. 좋아요 수, 랭킹 정보, 카테고리별 상품 수 등 +> + +### 🧱 구현 방식 예시 + +| 방법 | 설명 | 예시 | +| --- | --- | --- | +| Materialized View | 실제 테이블로 저장되는 View | PostgreSQL MV, batch insert | +| 조회용 테이블 | 자체적으로 만들어두는 별도 테이블 | `product_likes_view` 등 | +| 배치 or 이벤트 적재 | 주기적 or 발생 시점 집계 | Kafka, Scheduler 등 활용 | + +### 💡 실무 팁 & 연결 + +- 실시간 정합성이 **꼭 필요하지 않은 데이터**라면, Pre-aggregation 을 고려하세요 +- 이 전략은 후반 주차와 연결됩니다 → 그땐 Materialized View나 Batch 적재를 직접 다뤄볼 예정이에요! + + + +| 구분 | 링크 | +| --- | --- | +| 🔍 MySQL 인덱스 튜닝 | [쿼리 튜닝과 인덱스 최적화](https://wikidocs.net/226253) | +| 📖 인덱스 정렬 방향 이슈 | [카카오 테크 - MySQL 방향별 인덱스](https://tech.kakao.com/posts/351) | +| ⚙ Spring Cache 개요 | [Spring - Caching](https://docs.spring.io/spring-boot/reference/io/caching.html) | +| 🧰 RedisTemplate 사용법 | [Baeldung -Spring Data Redis](https://www.baeldung.com/spring-data-redis-tutorial) | +| 🧠 캐시 전략 비교 | [Inpa Dev - REDIS 캐시 설계 전략 지침](https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC) | +| 🌾 Materialized View 소개 | [AWS - Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) | \ No newline at end of file diff --git a/.docs/r5quest.md b/.docs/r5quest.md new file mode 100644 index 000000000..cc441d0b1 --- /dev/null +++ b/.docs/r5quest.md @@ -0,0 +1,131 @@ +# 📝 Round 5 Quests + +--- + +### 🤖 가정한 설계에 대해 점검하기 + +- **읽기 최적화에 대해** 고민해 보고, 그 구현 의도와 대안들에 대해 빠르게 분석하고 학습하는 데에 AI 를 활용해 봅니다. +- AI 는 제공된 구성과 설계에 대해 리스크 등을 검토하고 분석해 설계 의도를 명확히 하고, Trade-off 에 대해 이해합니다. +- **프롬프트 예시** + + ```markdown + 너는 대규모 트래픽 환경에서 백엔드 시스템 설계를 리뷰하는 시니어 아키텍트다. + 아래는 내가 설계한 구조 및 구현 코드에 대한 내용이다. + + [설계 설명] + - 조회 API 목적: + - 주요 조회 조건: + - 사용한 테이블/데이터: + - 해당 테이블의 인덱스: + - 캐시 적용 여부 및 위치: + - 캐시 키 전략: + - 캐시 TTL 가정: + + 위 내용을 기준으로 다음 관점에서 분석해줘. + + 1. 이 설계가 성립하기 위해 반드시 참이어야 하는 전제 조건은 무엇인가? + 2. 트래픽이 10배 증가했을 때 가장 먼저 병목이 될 지점은 어디인가? + 3. 캐시 적중률이 30% 이하로 떨어졌을 때 발생할 문제는? + 4. 데이터 정합성이 깨질 수 있는 시나리오를 2가지 이상 제시해줘. + 5. 이 설계를 유지하면서 가장 나중까지 미룰 수 있는 개선은 무엇인가? + 6. 반대로, 가장 먼저 손대야 할 위험 요소는 무엇인가? + + ❗주의: + - 구현 코드나 설정 값은 제안하지 마라. + - 구조적 리스크와 사고 관점에서만 답변하라. + ``` + + +--- + +## 💻 Implementation Quest + +> 실제 트래픽에서 자주 발생하는 조회 병목 문제를 해결하는 방법을 실습합니다. +> +> +> 좋아요 수 기반 정렬, 브랜드 필터링, 인기 상품 조회 등에서 성능 저하가 발생할 수 있습니다. +> +> 이를 인덱스, 비정규화, 캐시 등 구조적인 접근으로 해결하는 것이 목표입니다. +> + + + +### 📋 과제 정보 + +아래 세 가지 **성능 개선을 수행**합니다. + +> 모두 수행하는 것이 더 좋습니다. 선택 이유 및 AS-IS, TO-BE 에 대해서는 블로그에 첨부해 주세요. +> + +--- + +**① 상품 목록 조회 성능 개선** + +- 상품 데이터를 10만개 이상 준비합니다 (각 컬럼의 값은 다양하게 분포하도록 합니다 ) +- 브랜드 필터 + 좋아요 순 정렬 기능을 구현하고, **`EXPLAIN`** 분석을 통해 인덱스 최적화를 수행합니다. +- 성능 개선 전후 비교를 포함해 주세요. + +**② 좋아요 수 정렬 구조 개선** + +- **비정규화**(**`like_count`**) 혹은 **MaterializedView** 중 하나를 선택하여 좋아요 수 정렬 성능을 개선합니다. +- 좋아요 등록/취소 시 count 동기화 처리 방식이 누락되어 있다면 이 또한 함께 구현합니다. + +**③ 캐시 적용** + +- 상품 상세 API 및 상품 목록 API에 **Redis 캐시**를 적용합니다. +- TTL 설정, 캐시 키 설계, 무효화 전략 중 하나 이상 포함해 주세요. + +--- + +## ✅ Checklist + +### 🔖 Index + +- [ ] 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다 +- [ ] 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다 + +### ❤️ Structure + +- [ ] 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다 +- [ ] 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다 + +### ⚡ Cache + +- [ ] Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다 +- [ ] 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다. + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + + +### 🎯 Feature Suggestions + +- 좋아요 순으로 정렬하자 서버가 하염없이 느려졌다. +- 우리가 책을 읽을 때, 책갈피가 필요한 이유 (Feat. Index) +- 상품 목록에 좋아요 수를 포함하려고 했더니! +- 캐시를 적용했더니 실제 DB 로 가던 호출이 줄어들었다. +- 캐시 전략에서 TTL, 키 설계, 무효화 기준은 어떻게 결정했는가? +- 정합성과 성능 사이에서 어떤 트레이드오프를 선택했는가? \ No newline at end of file diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 2b317600f..fa55ccfd8 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -6,6 +6,9 @@ dependencies { implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // cache + implementation("com.github.ben-manes.caffeine:caffeine") + // security-crypto implementation("org.springframework.security:spring-security-crypto") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageReadCache.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageReadCache.java new file mode 100644 index 000000000..871b656b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageReadCache.java @@ -0,0 +1,12 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; + +import java.util.function.Supplier; + +public interface ProductPageReadCache { + PageResult get(ProductSortType sort, int page, int size, + Supplier> loader); + void evictAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java new file mode 100644 index 000000000..6cb9447e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; + +public interface ProductQueryService { + ProductReadModel getById(Long id); + PageResult getAll(Long brandId, ProductSortType sort, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadCache.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadCache.java new file mode 100644 index 000000000..23658c390 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadCache.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +import java.util.function.Supplier; + +public interface ProductReadCache { + ProductReadModel get(Long productId, Supplier loader); + void evict(Long productId); + void evictAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadModel.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadModel.java new file mode 100644 index 000000000..67f3cd17c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadModel.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductReadModel( + Long id, + Long brandId, + String name, + int price, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static ProductReadModel from(Product product) { + return new ProductReadModel( + product.getId(), + product.getBrandId(), + product.getName(), + product.getPrice().amount(), + product.getLikeCount(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java new file mode 100644 index 000000000..b5d7c902e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -0,0 +1,25 @@ +package com.loopers.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductReadModel; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +public class CacheConfig { + + @Bean + public Cache productDetailCache( + @Value("${cache.product.maximum-size:10000}") long maximumSize, + @Value("${cache.product.expire-after-write-minutes:5}") long expireAfterWriteMinutes + ) { + return Caffeine.newBuilder() + .maximumSize(maximumSize) + .expireAfterWrite(expireAfterWriteMinutes, TimeUnit.MINUTES) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 05d369998..d2d40ca64 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -5,10 +5,16 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; @Entity -@Table(name = "products") +@Table(name = "products", indexes = { + @Index(name = "idx_prod_brand_like", columnList = "brand_id, deleted_at, like_count DESC"), + @Index(name = "idx_prod_brand_price", columnList = "brand_id, deleted_at, price ASC"), + @Index(name = "idx_prod_brand_created", columnList = "brand_id, deleted_at, created_at DESC"), + @Index(name = "idx_prod_deleted_like", columnList = "deleted_at, like_count DESC") +}) public class Product extends BaseEntity { @Column(name = "brand_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductPageReadCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductPageReadCache.java new file mode 100644 index 000000000..69f312512 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductPageReadCache.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductPageReadCache; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Component +public class CaffeineProductPageReadCache implements ProductPageReadCache { + + private final Cache> cache; + + public CaffeineProductPageReadCache( + @Value("${cache.product-page.maximum-size:200}") long maximumSize, + @Value("${cache.product-page.expire-after-write-minutes:5}") long expireAfterWriteMinutes + ) { + this.cache = Caffeine.newBuilder() + .maximumSize(maximumSize) + .expireAfterWrite(expireAfterWriteMinutes, TimeUnit.MINUTES) + .build(); + } + + @Override + public PageResult get(ProductSortType sort, int page, int size, + Supplier> loader) { + return cache.get(new ProductPageCacheKey(sort, page, size), key -> loader.get()); + } + + @Override + public void evictAll() { + cache.invalidateAll(); + } + + private record ProductPageCacheKey(ProductSortType sort, int page, int size) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductReadCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductReadCache.java new file mode 100644 index 000000000..cf07c5168 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductReadCache.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.function.Supplier; + +@RequiredArgsConstructor +@Component +public class CaffeineProductReadCache implements ProductReadCache { + + private final Cache productDetailCache; + + @Override + public ProductReadModel get(Long productId, Supplier loader) { + return productDetailCache.get(productId, key -> loader.get()); + } + + @Override + public void evict(Long productId) { + productDetailCache.invalidate(productId); + } + + @Override + public void evictAll() { + productDetailCache.invalidateAll(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java new file mode 100644 index 000000000..9f0c18896 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductPageReadCache; +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductQueryServiceImpl implements ProductQueryService { + + private final ProductJpaRepository productJpaRepository; + private final ProductReadCache productReadCache; + private final ProductPageReadCache productPageReadCache; + + @Override + public ProductReadModel getById(Long id) { + ProductReadModel result = productReadCache.get(id, () -> + productJpaRepository.findByIdAndDeletedAtIsNull(id) + .map(ProductReadModel::from) + .orElse(null) + ); + if (result == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + return result; + } + + @Override + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { + if (brandId != null) { + return queryFromDb(brandId, sort, page, size); + } + return productPageReadCache.get(sort, page, size, () -> queryFromDb(null, sort, page, size)); + } + + private PageResult queryFromDb(Long brandId, ProductSortType sort, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); + + Page result; + if (brandId != null) { + result = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageRequest); + } else { + result = productJpaRepository.findAllByDeletedAtIsNull(pageRequest); + } + + return new PageResult<>( + result.getContent().stream().map(ProductReadModel::from).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } + + private Sort toSort(ProductSortType sortType) { + Sort primary = switch (sortType) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + return primary.and(Sort.by(Sort.Direction.DESC, "id")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index ef11cac6f..77926c25a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,5 +1,7 @@ package com.loopers.infrastructure.product; +import com.loopers.application.product.ProductPageReadCache; +import com.loopers.application.product.ProductReadCache; import com.loopers.domain.PageResult; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -9,6 +11,8 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Collection; import java.util.List; @@ -19,10 +23,14 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final ProductReadCache productReadCache; + private final ProductPageReadCache productPageReadCache; @Override public Product save(Product product) { - return productJpaRepository.save(product); + Product saved = productJpaRepository.save(product); + evictAfterCommit(saved.getId()); + return saved; } @Override @@ -58,16 +66,55 @@ public PageResult findAll(Long brandId, ProductSortType sort, int page, @Override public void softDeleteAllByBrandId(Long brandId) { productJpaRepository.softDeleteAllByBrandId(brandId); + evictAllAfterCommit(); } @Override public int incrementLikeCount(Long id) { - return productJpaRepository.incrementLikeCount(id); + int updated = productJpaRepository.incrementLikeCount(id); + evictAfterCommit(id); + return updated; } @Override public int decrementLikeCount(Long id) { - return productJpaRepository.decrementLikeCount(id); + int updated = productJpaRepository.decrementLikeCount(id); + evictAfterCommit(id); + return updated; + } + + private void evictAfterCommit(Long id) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + productReadCache.evict(id); + productPageReadCache.evictAll(); + } + } + ); + } else { + productReadCache.evict(id); + productPageReadCache.evictAll(); + } + } + + private void evictAllAfterCommit() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + productReadCache.evictAll(); + productPageReadCache.evictAll(); + } + } + ); + } else { + productReadCache.evictAll(); + productPageReadCache.evictAll(); + } } private Sort toSort(ProductSortType sortType) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 2495b0edd..b786f4dd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -13,6 +13,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -51,6 +52,16 @@ public ResponseEntity> handleBadRequest(MethodArgumentNotValidExc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(HandlerMethodValidationException e) { + String message = e.getAllValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream()) + .map(error -> error.getDefaultMessage()) + .findFirst() + .orElse(ErrorType.BAD_REQUEST.getMessage()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index c7dd9539a..859a4ecad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,40 +1,57 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductApplicationService; -import com.loopers.application.product.ProductPageWithBrands; -import com.loopers.application.product.ProductWithBrand; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/products") +@Validated public class ProductV1Controller implements ProductV1ApiSpec { - private final ProductApplicationService productApplicationService; + private final ProductQueryService productQueryService; + private final BrandApplicationService brandApplicationService; @GetMapping @Override public ApiResponse getAll( @RequestParam(required = false) Long brandId, @RequestParam(defaultValue = "latest") String sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page, + @RequestParam(defaultValue = "20") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") int size ) { - ProductPageWithBrands result = productApplicationService.getAll(brandId, ProductSortType.from(sort), page, size); - return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result.result(), result.brandMap())); + PageResult products = productQueryService.getAll( + brandId, ProductSortType.from(sort), page, size + ); + Set brandIds = products.items().stream() + .map(ProductReadModel::brandId) + .collect(Collectors.toSet()); + Map brandMap = brandApplicationService.getByIds(brandIds); + return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(products, brandMap)); } @GetMapping("/{productId}") @Override public ApiResponse getById(@PathVariable Long productId) { - ProductWithBrand result = productApplicationService.getProductWithBrand(productId); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(result.product(), result.brand())); + ProductReadModel product = productQueryService.getById(productId); + Brand brand = brandApplicationService.getById(product.brandId()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brand)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 428772d85..f3e003874 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductReadModel; import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; @@ -17,10 +19,10 @@ public record ProductResponse( int price, int likeCount ) { - public static ProductResponse from(Product product, Brand brand) { + public static ProductResponse from(ProductReadModel product, Brand brand) { return new ProductResponse( - product.getId(), product.getBrandId(), brand.getName(), product.getName(), - product.getPrice().amount(), product.getLikeCount() + product.id(), product.brandId(), brand.getName(), product.name(), + product.price(), product.likeCount() ); } } @@ -32,10 +34,15 @@ public record ProductPageResponse( long totalElements, int totalPages ) { - public static ProductPageResponse from(PageResult result, Map brandMap) { + public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .filter(product -> brandMap.containsKey(product.getBrandId())) - .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) + .map(product -> { + Brand brand = brandMap.get(product.brandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return ProductResponse.from(product, brand); + }) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java index 3a4a5a4bb..80d638c89 100644 --- a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java @@ -30,11 +30,25 @@ class ArchitectureTest { .layer("Config").definedBy("..config..") .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() - .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces", "Infrastructure", "Config") .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Interfaces", "Config") .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer() .whereLayer("Config").mayNotBeAccessedByAnyLayer(); + // ── 1-1. Infrastructure/Config는 ApplicationService에 직접 의존 금지 ──────── + // query-side 포트(QueryService, ReadCache, ReadModel 등)만 참조 허용 + @ArchTest + static final ArchRule infrastructure_should_not_depend_on_application_services = noClasses() + .that().resideInAPackage("..infrastructure..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("ApplicationService"); + + @ArchTest + static final ArchRule config_should_not_depend_on_application_services = noClasses() + .that().resideInAPackage("..config..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("ApplicationService"); + // ── 2. Domain 계층 독립성 (DIP 핵심) ──────────────────────────────────────── @ArchTest static final ArchRule domain_should_not_depend_on_infrastructure = noClasses() diff --git a/apps/commerce-api/src/test/java/com/loopers/benchmark/ProductIndexBenchmarkTest.java b/apps/commerce-api/src/test/java/com/loopers/benchmark/ProductIndexBenchmarkTest.java new file mode 100644 index 000000000..3df9a6c08 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/benchmark/ProductIndexBenchmarkTest.java @@ -0,0 +1,499 @@ +package com.loopers.benchmark; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import javax.sql.DataSource; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 상품 조회 인덱스 벤치마크 테스트. + * + * 실행 방법: + * ./gradlew :apps:commerce-api:test --tests "com.loopers.benchmark.ProductIndexBenchmarkTest" + * + * DATA_SIZE 를 변경하여 10만/20만/50만/100만건 비교 가능. + */ +@SpringBootTest +@Tag("benchmark") +class ProductIndexBenchmarkTest { + + @Autowired + private DataSource dataSource; + + // ==================== 설정 ==================== + private static final int DATA_SIZE = 100_000; + private static final int BRAND_COUNT = 100; + private static final int BATCH_SIZE = 5_000; + private static final int QUERY_RUNS = 3; + + private final StringBuilder report = new StringBuilder(); + private final Map> strategyTimings = new LinkedHashMap<>(); + private final Map brandProductCounts = new TreeMap<>(); + + private long popularBrandId; + private long mediumBrandId; + + // ==================== 메인 ==================== + + @Test + void benchmark() throws Exception { + seedData(); + analyzeTable(); // 통계 정보 갱신 (모든 전략에서 공정한 비교를 위해) + initReport(); + + List strategies = defineStrategies(); + for (IndexStrategy strategy : strategies) { + runStrategy(strategy); + } + + appendSummaryTable(strategies); + writeReport(); + } + + private void analyzeTable() throws SQLException { + try (Connection conn = dataSource.getConnection()) { + conn.createStatement().execute("ANALYZE TABLE products"); + } + } + + // ==================== 데이터 시딩 ==================== + + private void seedData() throws SQLException { + Random random = new Random(42); + double[] brandCdf = buildZipfCdf(BRAND_COUNT); + int[] basePrices = {5_000, 9_900, 15_000, 19_900, 29_900, 39_900, 49_900, 79_900, 99_000, 149_000, 199_000, 299_000, 499_000}; + + long start = System.currentTimeMillis(); + + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + + String sql = "INSERT INTO products (brand_id, name, price, like_count, created_at, updated_at, deleted_at) " + + "VALUES (?, ?, ?, ?, ?, ?, NULL)"; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + for (int i = 0; i < DATA_SIZE; i++) { + long brandId = sampleFromCdf(random, brandCdf) + 1; + int base = basePrices[random.nextInt(basePrices.length)]; + int price = (int) (base * (0.8 + random.nextDouble() * 0.4)); + int likeCount = Math.max(0, Math.min((int) Math.exp(random.nextGaussian() * 2.0 + 2.5), 100_000)); + + long daysAgo = random.nextInt(365); + Timestamp createdAt = Timestamp.valueOf( + LocalDateTime.now().minusDays(daysAgo).minusHours(random.nextInt(24)) + ); + + ps.setLong(1, brandId); + ps.setString(2, "Product-" + (i + 1)); + ps.setInt(3, price); + ps.setInt(4, likeCount); + ps.setTimestamp(5, createdAt); + ps.setTimestamp(6, createdAt); + ps.addBatch(); + + brandProductCounts.merge(brandId, 1, Integer::sum); + + if ((i + 1) % BATCH_SIZE == 0) { + ps.executeBatch(); + } + } + ps.executeBatch(); + } + + conn.commit(); + } + + // 인기 브랜드(상품 최다), 중간 브랜드 선정 + List> sorted = brandProductCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + popularBrandId = sorted.get(0).getKey(); + mediumBrandId = sorted.get(sorted.size() / 2).getKey(); + + long elapsed = System.currentTimeMillis() - start; + System.out.printf("✅ 데이터 시딩 완료: %,d건 (%dms)%n", DATA_SIZE, elapsed); + } + + // ==================== 리포트 헤더 ==================== + + private void initReport() throws SQLException { + report.append("# 📊 상품 조회 인덱스 벤치마크\n\n"); + report.append("> 실행일시: ") + .append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .append(" \n"); + report.append("> MySQL 8.0 (TestContainers)\n\n"); + + report.append("## 📋 테스트 환경\n\n"); + report.append("| 항목 | 값 |\n|---|---|\n"); + report.append(String.format("| 데이터 수 | %,d건 |\n", DATA_SIZE)); + report.append(String.format("| 브랜드 수 | %d개 (Zipf 분포) |\n", BRAND_COUNT)); + report.append("| 좋아요 분포 | Log-normal (중앙값 ~12, 롱테일) |\n"); + report.append(String.format("| 측정 횟수 | %d회 평균 |\n", QUERY_RUNS)); + report.append(String.format("| 인기 브랜드 ID | %d (%,d건) |\n", popularBrandId, brandProductCounts.get(popularBrandId))); + report.append(String.format("| 중간 브랜드 ID | %d (%,d건) |\n", mediumBrandId, brandProductCounts.get(mediumBrandId))); + report.append("\n"); + + // 브랜드 분포 상위 10개 + report.append("### 브랜드별 상품 수 (상위 10개)\n\n"); + report.append("| 순위 | 브랜드 ID | 상품 수 | 비율 |\n|---|---|---|---|\n"); + List> sorted = brandProductCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .toList(); + for (int i = 0; i < sorted.size(); i++) { + Map.Entry e = sorted.get(i); + report.append(String.format("| %d | %d | %,d | %.1f%% |\n", + i + 1, e.getKey(), e.getValue(), e.getValue() * 100.0 / DATA_SIZE)); + } + + // 좋아요 분포 + report.append("\n### 좋아요 수 분포\n\n"); + appendLikeDistribution(); + report.append("\n---\n\n"); + } + + private void appendLikeDistribution() throws SQLException { + String sql = """ + SELECT + CASE + WHEN like_count BETWEEN 0 AND 10 THEN '0~10' + WHEN like_count BETWEEN 11 AND 50 THEN '11~50' + WHEN like_count BETWEEN 51 AND 200 THEN '51~200' + WHEN like_count BETWEEN 201 AND 1000 THEN '201~1,000' + WHEN like_count BETWEEN 1001 AND 5000 THEN '1,001~5,000' + ELSE '5,001+' + END AS range_label, + COUNT(*) AS cnt, + MIN(like_count) AS range_min + FROM products + GROUP BY range_label + ORDER BY range_min + """; + + report.append("| 구간 | 상품 수 | 비율 |\n|---|---|---|\n"); + + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + int cnt = rs.getInt("cnt"); + report.append(String.format("| %s | %,d | %.1f%% |\n", + rs.getString("range_label"), cnt, cnt * 100.0 / DATA_SIZE)); + } + } + } + + // ==================== 인덱스 전략 정의 ==================== + + record IndexStrategy(String name, String description, List createSqls, List dropSqls) {} + + private List defineStrategies() { + return List.of( + new IndexStrategy( + "인덱스 없음 (Baseline)", + "PK(id)만 존재하는 상태", + List.of(), + List.of() + ), + new IndexStrategy( + "단일 인덱스: (brand_id)", + "가장 기본적인 필터 컬럼 인덱스", + List.of("CREATE INDEX idx_brand_id ON products(brand_id)"), + List.of("DROP INDEX idx_brand_id ON products") + ), + new IndexStrategy( + "복합 인덱스: (brand_id, deleted_at, like_count DESC)", + "브랜드 필터 + soft delete + 좋아요순 정렬까지 커버", + List.of("CREATE INDEX idx_brand_deleted_like ON products(brand_id, deleted_at, like_count DESC)"), + List.of("DROP INDEX idx_brand_deleted_like ON products") + ), + new IndexStrategy( + "전체 커버링 인덱스 세트", + "모든 조회 패턴에 최적화된 인덱스 조합 (쓰기 비용 증가 트레이드오프)", + List.of( + "CREATE INDEX idx_prod_brand_like ON products(brand_id, deleted_at, like_count DESC)", + "CREATE INDEX idx_prod_brand_price ON products(brand_id, deleted_at, price ASC)", + "CREATE INDEX idx_prod_brand_created ON products(brand_id, deleted_at, created_at DESC)", + "CREATE INDEX idx_prod_deleted_like ON products(deleted_at, like_count DESC)", + "CREATE INDEX idx_prod_deleted_price ON products(deleted_at, price ASC)" + ), + List.of( + "DROP INDEX idx_prod_brand_like ON products", + "DROP INDEX idx_prod_brand_price ON products", + "DROP INDEX idx_prod_brand_created ON products", + "DROP INDEX idx_prod_deleted_like ON products", + "DROP INDEX idx_prod_deleted_price ON products" + ) + ) + ); + } + + // ==================== 벤치마크 쿼리 정의 ==================== + + record BenchmarkQuery(String label, String sql) {} + + private List getQueries() { + return List.of( + new BenchmarkQuery("브랜드(인기) + 좋아요순", + "SELECT * FROM products WHERE brand_id = %d AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20".formatted(popularBrandId)), + new BenchmarkQuery("브랜드(인기) + 가격순", + "SELECT * FROM products WHERE brand_id = %d AND deleted_at IS NULL ORDER BY price ASC LIMIT 20".formatted(popularBrandId)), + new BenchmarkQuery("브랜드(인기) + 최신순", + "SELECT * FROM products WHERE brand_id = %d AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20".formatted(popularBrandId)), + new BenchmarkQuery("브랜드(중간) + 좋아요순", + "SELECT * FROM products WHERE brand_id = %d AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20".formatted(mediumBrandId)), + new BenchmarkQuery("전체 + 좋아요순", + "SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20"), + new BenchmarkQuery("전체 + 가격순", + "SELECT * FROM products WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20"), + new BenchmarkQuery("COUNT(브랜드 인기)", + "SELECT COUNT(*) FROM products WHERE brand_id = %d AND deleted_at IS NULL".formatted(popularBrandId)), + new BenchmarkQuery("COUNT(전체)", + "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL"), + // 딥 페이지네이션: OFFSET이 커질수록 성능 저하 확인 + new BenchmarkQuery("딥페이징: 좋아요순 OFFSET 10000", + "SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000"), + new BenchmarkQuery("딥페이징: 브랜드(인기)+좋아요순 OFFSET 5000", + "SELECT * FROM products WHERE brand_id = %d AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 5000".formatted(popularBrandId)) + ); + } + + // ==================== 전략 실행 ==================== + + private void runStrategy(IndexStrategy strategy) throws SQLException { + // 인덱스 생성 + try (Connection conn = dataSource.getConnection()) { + for (String sql : strategy.createSqls()) { + conn.createStatement().execute(sql); + } + if (!strategy.createSqls().isEmpty()) { + conn.createStatement().execute("ANALYZE TABLE products"); + } + } + + report.append("## 🔍 ").append(strategy.name()).append("\n\n"); + report.append("> ").append(strategy.description()).append("\n\n"); + + appendCurrentIndexes(); + + // 인덱스 생성에 사용된 DDL 표시 + if (!strategy.createSqls().isEmpty()) { + report.append("```sql\n"); + for (String sql : strategy.createSqls()) { + report.append(sql).append(";\n"); + } + report.append("```\n\n"); + } + + // 쿼리별 벤치마크 실행 + report.append("### 벤치마크 결과\n\n"); + report.append("| # | 쿼리 | type | possible_keys | key | rows | filtered | Extra | 수행시간 |\n"); + report.append("|---|---|---|---|---|---|---|---|---|\n"); + + List timings = new ArrayList<>(); + List queries = getQueries(); + for (int i = 0; i < queries.size(); i++) { + String timing = runSingleBenchmark(i + 1, queries.get(i)); + timings.add(timing); + } + strategyTimings.put(strategy.name(), timings); + + // 실행된 쿼리 상세 (접힘) + report.append("\n
실행된 쿼리 상세\n\n"); + for (int i = 0; i < queries.size(); i++) { + report.append("**").append(i + 1).append(". ").append(queries.get(i).label()).append("**\n"); + report.append("```sql\n").append(queries.get(i).sql()).append("\n```\n\n"); + } + report.append("
\n\n"); + report.append("---\n\n"); + + // 인덱스 제거 + try (Connection conn = dataSource.getConnection()) { + for (String sql : strategy.dropSqls()) { + try { conn.createStatement().execute(sql); } catch (Exception ignored) {} + } + } + } + + private String runSingleBenchmark(int idx, BenchmarkQuery query) throws SQLException { + // EXPLAIN 결과 수집 + Map explain = getExplain(query.sql()); + + // 수행시간 측정: warm-up 1회 + 측정 QUERY_RUNS회 평균 + long totalNanos = 0; + try (Connection conn = dataSource.getConnection()) { + // warm-up (버퍼 풀에 데이터 로드) + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query.sql())) { + while (rs.next()) { /* consume */ } + } + // 측정 + for (int i = 0; i < QUERY_RUNS; i++) { + long start = System.nanoTime(); + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query.sql())) { + while (rs.next()) { /* consume */ } + } + totalNanos += System.nanoTime() - start; + } + } + long avgMs = totalNanos / QUERY_RUNS / 1_000_000; + String timing = avgMs + "ms"; + + report.append(String.format("| %d | %s | %s | %s | %s | %s | %s | %s | **%s** |\n", + idx, + query.label(), + explain.getOrDefault("type", "-"), + truncate(explain.getOrDefault("possible_keys", "NULL"), 30), + explain.getOrDefault("key", "NULL"), + explain.getOrDefault("rows", "-"), + explain.getOrDefault("filtered", "-"), + explain.getOrDefault("Extra", "-"), + timing + )); + + return timing; + } + + // ==================== EXPLAIN 실행 ==================== + + private Map getExplain(String sql) throws SQLException { + Map result = new LinkedHashMap<>(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("EXPLAIN " + sql)) { + if (rs.next()) { + ResultSetMetaData meta = rs.getMetaData(); + for (int i = 1; i <= meta.getColumnCount(); i++) { + String val = rs.getString(i); + result.put(meta.getColumnLabel(i), val != null ? val : "NULL"); + } + } + } + return result; + } + + // ==================== 현재 인덱스 표시 ==================== + + private void appendCurrentIndexes() throws SQLException { + report.append("**적용된 인덱스:**\n\n"); + report.append("| Key_name | Column_name | Seq | Non_unique |\n|---|---|---|---|\n"); + + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW INDEX FROM products")) { + while (rs.next()) { + report.append(String.format("| %s | %s | %d | %d |\n", + rs.getString("Key_name"), + rs.getString("Column_name"), + rs.getInt("Seq_in_index"), + rs.getInt("Non_unique") + )); + } + } + report.append("\n"); + } + + // ==================== 요약 비교표 ==================== + + private void appendSummaryTable(List strategies) { + report.append("## 📈 전략별 성능 비교 요약\n\n"); + + List queries = getQueries(); + + // 헤더 + report.append("| 쿼리 |"); + for (IndexStrategy s : strategies) { + report.append(" ").append(s.name()).append(" |"); + } + report.append("\n|---|"); + for (int i = 0; i < strategies.size(); i++) report.append("---|"); + report.append("\n"); + + // 행 + for (int q = 0; q < queries.size(); q++) { + report.append("| ").append(queries.get(q).label()).append(" |"); + for (IndexStrategy s : strategies) { + List timings = strategyTimings.get(s.name()); + String val = (timings != null && q < timings.size()) ? timings.get(q) : "-"; + report.append(" ").append(val).append(" |"); + } + report.append("\n"); + } + } + + // ==================== 리포트 출력 ==================== + + private void writeReport() throws IOException { + String suffix = switch (DATA_SIZE) { + case 100_000 -> "100k"; + case 200_000 -> "200k"; + case 500_000 -> "500k"; + case 1_000_000 -> "1m"; + default -> String.valueOf(DATA_SIZE); + }; + + // .docs/ 디렉토리 탐색 (프로젝트 루트 기준) + Path docsDir = findDocsDir(); + Path outputPath = docsDir.resolve("index-benchmark-" + suffix + ".md"); + Files.writeString(outputPath, report.toString(), StandardCharsets.UTF_8); + + System.out.println("\n📄 리포트 저장: " + outputPath.toAbsolutePath()); + System.out.println("\n" + report); + } + + private Path findDocsDir() throws IOException { + Path dir = Path.of(System.getProperty("user.dir")); + for (int i = 0; i < 5; i++) { + Path docs = dir.resolve(".docs"); + if (Files.isDirectory(docs)) { + return docs; + } + dir = dir.getParent(); + if (dir == null) break; + } + // fallback: 현재 디렉토리에 생성 + Path fallback = Path.of(System.getProperty("user.dir"), ".docs"); + Files.createDirectories(fallback); + return fallback; + } + + // ==================== 유틸리티 ==================== + + private double[] buildZipfCdf(int n) { + double[] cdf = new double[n]; + double sum = 0; + for (int i = 0; i < n; i++) { + sum += 1.0 / (i + 1); + } + double cumulative = 0; + for (int i = 0; i < n; i++) { + cumulative += (1.0 / (i + 1)) / sum; + cdf[i] = cumulative; + } + return cdf; + } + + private int sampleFromCdf(Random random, double[] cdf) { + double r = random.nextDouble(); + for (int i = 0; i < cdf.length; i++) { + if (r <= cdf[i]) return i; + } + return cdf.length - 1; + } + + private String truncate(String s, int maxLen) { + if (s == null) return "NULL"; + return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java new file mode 100644 index 000000000..b7658153d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java @@ -0,0 +1,185 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class CaffeineProductPageReadCacheTest { + + private CaffeineProductPageReadCache pageReadCache; + + @BeforeEach + void setUp() { + pageReadCache = new CaffeineProductPageReadCache(100, 5); + } + + private PageResult createPage(int itemCount) { + ZonedDateTime now = ZonedDateTime.now(); + List items = java.util.stream.IntStream.range(0, itemCount) + .mapToObj(i -> new ProductReadModel((long) i, 1L, "상품" + i, 10000 * (i + 1), 0, now, now)) + .toList(); + return new PageResult<>(items, 0, 20, itemCount, 1); + } + + @DisplayName("get을 호출할 때, ") + @Nested + class Get { + + @DisplayName("캐시 미스이면, loader를 호출하여 값을 저장하고 반환한다.") + @Test + void callsLoader_whenCacheMiss() { + // given + AtomicInteger loaderCallCount = new AtomicInteger(0); + PageResult expected = createPage(2); + + // when + PageResult result = pageReadCache.get( + ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return expected; + } + ); + + // then + assertThat(result.items()).hasSize(2); + assertThat(loaderCallCount.get()).isEqualTo(1); + } + + @DisplayName("캐시 히트이면, loader를 호출하지 않고 캐시된 값을 반환한다.") + @Test + void skipsLoader_whenCacheHit() { + // given + PageResult expected = createPage(2); + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> expected); // 캐시 채움 + + AtomicInteger loaderCallCount = new AtomicInteger(0); + + // when + PageResult result = pageReadCache.get( + ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(5); + } + ); + + // then + assertThat(result.items()).hasSize(2); + assertThat(loaderCallCount.get()).isEqualTo(0); + } + + @DisplayName("정렬 기준이 다르면, 별도 캐시 엔트리로 관리된다.") + @Test + void separatesCacheBySort() { + // given + PageResult latestPage = createPage(2); + PageResult pricePage = createPage(3); + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> latestPage); + pageReadCache.get(ProductSortType.PRICE_ASC, 0, 20, () -> pricePage); + + // when & then + AtomicInteger loaderCallCount = new AtomicInteger(0); + PageResult result = pageReadCache.get( + ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(10); + } + ); + assertThat(result.items()).hasSize(2); // 캐시된 latestPage 반환 + assertThat(loaderCallCount.get()).isEqualTo(0); + } + + @DisplayName("페이지 번호가 다르면, 별도 캐시 엔트리로 관리된다.") + @Test + void separatesCacheByPage() { + // given + PageResult page0 = createPage(2); + PageResult page1 = createPage(3); + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> page0); + pageReadCache.get(ProductSortType.LATEST, 1, 20, () -> page1); + + // when & then + AtomicInteger loaderCallCount = new AtomicInteger(0); + PageResult result = pageReadCache.get( + ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(10); + } + ); + assertThat(result.items()).hasSize(2); // 캐시된 page0 반환 + assertThat(loaderCallCount.get()).isEqualTo(0); + + PageResult result1 = pageReadCache.get( + ProductSortType.LATEST, 1, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(10); + } + ); + assertThat(result1.items()).hasSize(3); // 캐시된 page1 반환 + assertThat(loaderCallCount.get()).isEqualTo(0); + } + + @DisplayName("페이지 크기가 다르면, 별도 캐시 엔트리로 관리된다.") + @Test + void separatesCacheBySize() { + // given + PageResult size20 = createPage(2); + PageResult size10 = createPage(3); + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> size20); + pageReadCache.get(ProductSortType.LATEST, 0, 10, () -> size10); + + // when & then + AtomicInteger loaderCallCount = new AtomicInteger(0); + PageResult result = pageReadCache.get( + ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(10); + } + ); + assertThat(result.items()).hasSize(2); // 캐시된 size20 반환 + assertThat(loaderCallCount.get()).isEqualTo(0); + + PageResult result10 = pageReadCache.get( + ProductSortType.LATEST, 0, 10, () -> { + loaderCallCount.incrementAndGet(); + return createPage(10); + } + ); + assertThat(result10.items()).hasSize(3); // 캐시된 size10 반환 + assertThat(loaderCallCount.get()).isEqualTo(0); + } + } + + @DisplayName("evictAll을 호출할 때, ") + @Nested + class EvictAll { + + @DisplayName("모든 캐시가 제거되어 loader가 다시 호출된다.") + @Test + void removesAllEntries() { + // given + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> createPage(2)); + pageReadCache.get(ProductSortType.PRICE_ASC, 0, 20, () -> createPage(3)); + + // when + pageReadCache.evictAll(); + + // then + AtomicInteger loaderCallCount = new AtomicInteger(0); + pageReadCache.get(ProductSortType.LATEST, 0, 20, () -> { + loaderCallCount.incrementAndGet(); + return createPage(5); + }); + assertThat(loaderCallCount.get()).isEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductReadCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductReadCacheTest.java new file mode 100644 index 000000000..fba8becb8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductReadCacheTest.java @@ -0,0 +1,132 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.loopers.application.product.ProductReadModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.ZonedDateTime; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class CaffeineProductReadCacheTest { + + private Cache caffeineCache; + private CaffeineProductReadCache productReadCache; + + @BeforeEach + void setUp() { + caffeineCache = Caffeine.newBuilder().build(); + productReadCache = new CaffeineProductReadCache(caffeineCache); + } + + private ProductReadModel createReadModel(Long id, String name) { + ZonedDateTime now = ZonedDateTime.now(); + return new ProductReadModel(id, 1L, name, 129000, 0, now, now); + } + + @DisplayName("get을 호출할 때, ") + @Nested + class Get { + + @DisplayName("캐시 미스이면, loader를 호출하여 값을 저장하고 반환한다.") + @Test + void callsLoader_whenCacheMiss() { + // given + AtomicInteger loaderCallCount = new AtomicInteger(0); + ProductReadModel expected = createReadModel(1L, "에어맥스"); + + // when + ProductReadModel result = productReadCache.get(1L, () -> { + loaderCallCount.incrementAndGet(); + return expected; + }); + + // then + assertThat(result).isEqualTo(expected); + assertThat(loaderCallCount.get()).isEqualTo(1); + assertThat(caffeineCache.getIfPresent(1L)).isEqualTo(expected); + } + + @DisplayName("캐시 히트이면, loader를 호출하지 않고 캐시된 값을 반환한다.") + @Test + void skipsLoader_whenCacheHit() { + // given + ProductReadModel expected = createReadModel(1L, "에어맥스"); + caffeineCache.put(1L, expected); + AtomicInteger loaderCallCount = new AtomicInteger(0); + + // when + ProductReadModel result = productReadCache.get(1L, () -> { + loaderCallCount.incrementAndGet(); + return createReadModel(1L, "다른상품"); + }); + + // then + assertThat(result).isEqualTo(expected); + assertThat(loaderCallCount.get()).isEqualTo(0); + } + + @DisplayName("loader가 null을 반환하면, 캐시에 저장하지 않고 null을 반환한다.") + @Test + void returnsNull_whenLoaderReturnsNull() { + // when + ProductReadModel result = productReadCache.get(1L, () -> null); + + // then + assertThat(result).isNull(); + assertThat(caffeineCache.getIfPresent(1L)).isNull(); + } + } + + @DisplayName("evict를 호출할 때, ") + @Nested + class Evict { + + @DisplayName("해당 키의 캐시가 제거되어 재조회 시 loader가 다시 호출된다.") + @Test + void removesEntry_andLoaderIsCalledAgain() { + // given + ProductReadModel original = createReadModel(1L, "에어맥스"); + caffeineCache.put(1L, original); + + // when + productReadCache.evict(1L); + + // then + assertThat(caffeineCache.getIfPresent(1L)).isNull(); + + AtomicInteger loaderCallCount = new AtomicInteger(0); + ProductReadModel updated = createReadModel(1L, "에어포스1"); + productReadCache.get(1L, () -> { + loaderCallCount.incrementAndGet(); + return updated; + }); + assertThat(loaderCallCount.get()).isEqualTo(1); + } + } + + @DisplayName("evictAll을 호출할 때, ") + @Nested + class EvictAll { + + @DisplayName("모든 캐시가 제거된다.") + @Test + void removesAllEntries() { + // given + caffeineCache.put(1L, createReadModel(1L, "에어맥스")); + caffeineCache.put(2L, createReadModel(2L, "에어포스1")); + + // when + productReadCache.evictAll(); + + // then + assertThat(caffeineCache.getIfPresent(1L)).isNull(); + assertThat(caffeineCache.getIfPresent(2L)).isNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..be4b759ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java @@ -0,0 +1,213 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.loopers.application.product.ProductPageReadCache; +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ProductCacheIntegrationTest { + + @Autowired + private ProductQueryService productQueryService; + + @Autowired + private ProductDomainService productService; + + @Autowired + private BrandDomainService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private ProductReadCache productReadCache; + + @Autowired + private ProductPageReadCache productPageReadCache; + + @Autowired + private Cache productDetailCache; + + private Long brandId; + + @BeforeEach + void setUp() { + productReadCache.evictAll(); + productPageReadCache.evictAll(); + Brand brand = brandService.register("나이키"); + brandId = brand.getId(); + } + + @AfterEach + void tearDown() { + productReadCache.evictAll(); + productPageReadCache.evictAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상세 캐시 afterCommit 무효화를 검증할 때, ") + @Nested + class DetailCacheEviction { + + @DisplayName("상품 수정 커밋 후, 캐시가 무효화되어 최신 값을 반환한다.") + @Test + void evictsCache_afterProductUpdate() { + // given + Product product = productService.register(brandId, "에어맥스", 129000); + ProductReadModel cached = productQueryService.getById(product.getId()); + assertThat(cached.name()).isEqualTo("에어맥스"); + + // when + transactionTemplate.executeWithoutResult(status -> + productService.update(product.getId(), "에어포스1", 109000) + ); + + // then + ProductReadModel result = productQueryService.getById(product.getId()); + assertThat(result.name()).isEqualTo("에어포스1"); + assertThat(result.price()).isEqualTo(109000); + } + + @DisplayName("좋아요 증가 커밋 후, 상세 조회에서 likeCount가 최신값으로 반영된다.") + @Test + void evictsCache_afterLikeIncrement() { + // given + Product product = productService.register(brandId, "에어맥스", 129000); + ProductReadModel cached = productQueryService.getById(product.getId()); + assertThat(cached.likeCount()).isEqualTo(0); + + // when + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); + + // then + ProductReadModel result = productQueryService.getById(product.getId()); + assertThat(result.likeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 감소 커밋 후, 상세 조회에서 likeCount가 최신값으로 반영된다.") + @Test + void evictsCache_afterLikeDecrement() { + // given + Product product = productService.register(brandId, "에어맥스", 129000); + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); + ProductReadModel cached = productQueryService.getById(product.getId()); + assertThat(cached.likeCount()).isEqualTo(1); + + // when + transactionTemplate.executeWithoutResult(status -> + productService.decrementLikeCount(product.getId()) + ); + + // then + ProductReadModel result = productQueryService.getById(product.getId()); + assertThat(result.likeCount()).isEqualTo(0); + } + + @DisplayName("상품 삭제 커밋 후, 캐시가 무효화된다.") + @Test + void evictsCache_afterProductDelete() { + // given + Product product = productService.register(brandId, "에어맥스", 129000); + productQueryService.getById(product.getId()); + + // when + transactionTemplate.executeWithoutResult(status -> + productService.delete(product.getId()) + ); + + // then + assertThat(productDetailCache.getIfPresent(product.getId())).isNull(); + } + + @DisplayName("브랜드 삭제 커밋 후, 해당 브랜드 상품의 상세 캐시가 무효화된다.") + @Test + void evictsAllDetailCache_afterBrandDelete() { + // given + Product product1 = productService.register(brandId, "에어맥스", 129000); + Product product2 = productService.register(brandId, "에어포스1", 109000); + productQueryService.getById(product1.getId()); + productQueryService.getById(product2.getId()); + + // when + transactionTemplate.executeWithoutResult(status -> { + brandService.deleteWithLock(brandId); + productService.deleteAllByBrandId(brandId); + }); + + // then + assertThat(productDetailCache.getIfPresent(product1.getId())).isNull(); + assertThat(productDetailCache.getIfPresent(product2.getId())).isNull(); + } + } + + @DisplayName("페이지 캐시를 검증할 때, ") + @Nested + class PageCacheEviction { + + @DisplayName("전체 목록 조회 시 캐시가 적용되고, 상품 변경 커밋 후 무효화된다.") + @Test + void evictsPageCache_afterProductUpdate() { + // given + productService.register(brandId, "에어맥스", 129000); + productService.register(brandId, "에어포스1", 109000); + + PageResult cached = productQueryService.getAll( + null, ProductSortType.LATEST, 0, 20 + ); + assertThat(cached.items()).hasSize(2); + + // when — 새 상품 등록 후 커밋 + productService.register(brandId, "뉴발란스 990", 199000); + + // then — 페이지 캐시 무효화되어 3개 반환 + PageResult result = productQueryService.getAll( + null, ProductSortType.LATEST, 0, 20 + ); + assertThat(result.items()).hasSize(3); + } + + @DisplayName("브랜드 필터 조회는 캐시하지 않는다.") + @Test + void doesNotCacheBrandFilteredQueries() { + // given + productService.register(brandId, "에어맥스", 129000); + + productQueryService.getAll(brandId, ProductSortType.LATEST, 0, 20); + + // when — 새 상품 등록 (캐시 무효화 없이도 최신값이어야 함) + productService.register(brandId, "에어포스1", 109000); + + // then — 캐시가 아닌 DB 직접 조회이므로 2개 반환 + PageResult result = productQueryService.getAll( + brandId, ProductSortType.LATEST, 0, 20 + ); + assertThat(result.items()).hasSize(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryServiceImplTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryServiceImplTest.java new file mode 100644 index 000000000..d5bbbc1e5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryServiceImplTest.java @@ -0,0 +1,181 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductPageReadCache; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class ProductQueryServiceImplTest { + + private ProductJpaRepository productJpaRepository; + private ProductReadCache productReadCache; + private ProductPageReadCache productPageReadCache; + private ProductQueryServiceImpl productQueryService; + + @BeforeEach + void setUp() { + productJpaRepository = mock(ProductJpaRepository.class); + productReadCache = mock(ProductReadCache.class); + productPageReadCache = mock(ProductPageReadCache.class); + productQueryService = new ProductQueryServiceImpl( + productJpaRepository, productReadCache, productPageReadCache + ); + } + + @DisplayName("getById를 호출할 때, ") + @Nested + class GetById { + + @DisplayName("캐시에서 값을 반환하면, 그대로 반환한다.") + @Test + void returnsValue_whenCacheReturnsValue() { + // given + ProductReadModel readModel = new ProductReadModel( + 1L, 1L, "에어맥스", 129000, 0, ZonedDateTime.now(), ZonedDateTime.now() + ); + given(productReadCache.get(eq(1L), any(Supplier.class))).willReturn(readModel); + + // when + ProductReadModel result = productQueryService.getById(1L); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + assertThat(result.price()).isEqualTo(129000); + verify(productReadCache).get(eq(1L), any(Supplier.class)); + } + + @DisplayName("캐시에서 null을 반환하면, CoreException을 던진다.") + @Test + void throwsNotFound_whenCacheReturnsNull() { + // given + given(productReadCache.get(eq(999L), any(Supplier.class))).willReturn(null); + + // when & then + assertThatThrownBy(() -> productQueryService.getById(999L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("전체 필드가 보존된다.") + @Test + void preservesAllFields() { + // given + ZonedDateTime now = ZonedDateTime.now(); + ProductReadModel readModel = new ProductReadModel(1L, 2L, "에어맥스", 129000, 5, now, now); + given(productReadCache.get(eq(1L), any(Supplier.class))).willReturn(readModel); + + // when + ProductReadModel result = productQueryService.getById(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.brandId()).isEqualTo(2L); + assertThat(result.name()).isEqualTo("에어맥스"); + assertThat(result.price()).isEqualTo(129000); + assertThat(result.likeCount()).isEqualTo(5); + assertThat(result.createdAt()).isEqualTo(now); + assertThat(result.updatedAt()).isEqualTo(now); + } + } + + @DisplayName("getAll을 호출할 때, ") + @Nested + class GetAll { + + @DisplayName("brandId가 null이면, 페이지 캐시를 사용한다.") + @Test + void usesPageCache_whenBrandIdIsNull() { + // given + PageResult cachedPage = new PageResult<>(List.of(), 0, 20, 0, 0); + given(productPageReadCache.get( + eq(ProductSortType.LATEST), eq(0), eq(20), any(Supplier.class) + )).willReturn(cachedPage); + + // when + PageResult result = productQueryService.getAll( + null, ProductSortType.LATEST, 0, 20 + ); + + // then + assertThat(result).isEqualTo(cachedPage); + verify(productPageReadCache).get( + eq(ProductSortType.LATEST), eq(0), eq(20), any(Supplier.class) + ); + } + + @DisplayName("brandId가 있으면, 페이지 캐시를 사용하지 않고 DB에서 직접 조회한다.") + @Test + void queriesDbDirectly_whenBrandIdIsNotNull() { + // given + ZonedDateTime now = ZonedDateTime.now(); + Product product1 = new Product(1L, "에어맥스", new com.loopers.domain.product.Money(129000)); + ReflectionTestUtils.setField(product1, "id", 10L); + ReflectionTestUtils.setField(product1, "createdAt", now); + ReflectionTestUtils.setField(product1, "updatedAt", now); + + Product product2 = new Product(1L, "에어포스", new com.loopers.domain.product.Money(99000)); + ReflectionTestUtils.setField(product2, "id", 11L); + ReflectionTestUtils.setField(product2, "createdAt", now); + ReflectionTestUtils.setField(product2, "updatedAt", now); + + Sort expectedSort = Sort.by(Sort.Direction.DESC, "createdAt") + .and(Sort.by(Sort.Direction.DESC, "id")); + Page productPage = new PageImpl<>( + List.of(product1, product2), PageRequest.of(0, 20, expectedSort), 2 + ); + + ArgumentCaptor pageRequestCaptor = ArgumentCaptor.forClass(PageRequest.class); + given(productJpaRepository.findAllByBrandIdAndDeletedAtIsNull( + eq(1L), any(PageRequest.class) + )).willReturn(productPage); + + // when + PageResult result = productQueryService.getAll( + 1L, ProductSortType.LATEST, 0, 20 + ); + + // then + verify(productJpaRepository).findAllByBrandIdAndDeletedAtIsNull(eq(1L), pageRequestCaptor.capture()); + Sort capturedSort = pageRequestCaptor.getValue().getSort(); + assertThat(capturedSort).isEqualTo(expectedSort); + + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).name()).isEqualTo("에어맥스"); + assertThat(result.items().get(0).price()).isEqualTo(129000); + assertThat(result.items().get(1).name()).isEqualTo("에어포스"); + assertThat(result.items().get(1).price()).isEqualTo(99000); + assertThat(result.totalElements()).isEqualTo(2); + assertThat(result.totalPages()).isEqualTo(1); + + verify(productPageReadCache, never()).get( + any(ProductSortType.class), anyInt(), anyInt(), any(Supplier.class) + ); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f67870e8a..4b3b46438 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,7 +81,9 @@ subprojects { tasks.test { maxParallelForks = 1 - useJUnitPlatform() + useJUnitPlatform { + excludeTags("benchmark") + } systemProperty("user.timezone", "Asia/Seoul") systemProperty("spring.profiles.active", "test") jvmArgs("-Xshare:off")