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")