From 8f7a7089cf878a5da4545123c7be0454f467342b Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Tue, 10 Mar 2026 19:00:01 +0900 Subject: [PATCH 1/5] =?UTF-8?q?docs=20:=20=EB=B2=A4=EC=B9=98=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20report=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/index-benchmark-100k.md | 415 +++++++++++++++ .docs/index-benchmark-1m.md | 415 +++++++++++++++ .docs/index-benchmark-200k.md | 415 +++++++++++++++ .docs/index-benchmark-500k.md | 415 +++++++++++++++ .docs/index-optimization-report.md | 266 ++++++++++ .docs/practical_read_opt.md | 419 +++++++++++++++ .docs/r5quest.md | 131 +++++ .../benchmark/ProductIndexBenchmarkTest.java | 499 ++++++++++++++++++ build.gradle.kts | 4 +- 9 files changed, 2978 insertions(+), 1 deletion(-) create mode 100644 .docs/index-benchmark-100k.md create mode 100644 .docs/index-benchmark-1m.md create mode 100644 .docs/index-benchmark-200k.md create mode 100644 .docs/index-benchmark-500k.md create mode 100644 .docs/index-optimization-report.md create mode 100644 .docs/practical_read_opt.md create mode 100644 .docs/r5quest.md create mode 100644 apps/commerce-api/src/test/java/com/loopers/benchmark/ProductIndexBenchmarkTest.java 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/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/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") From 7a3a51cadfb28058447f73ba4500b61dc441afc9 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Tue, 10 Mar 2026 19:00:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat=20:=20Product=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/product/Product.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) From 460fdaa78c76b16f95f4e85b7caef27dccf0c4f1 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Wed, 11 Mar 2026 21:23:37 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat=20:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=BA=90=EC=8B=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(=EB=B6=88=EB=B3=80=20ReadModel=20+=20afterCommit?= =?UTF-8?q?=20=EB=AC=B4=ED=9A=A8=ED=99=94=20+=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EC=83=81=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductReadModel(๋ถˆ๋ณ€ record) ๊ธฐ๋ฐ˜ ์บ์‹œ๋กœ mutable ์—”ํ‹ฐํ‹ฐ ์บ์‹ฑ ๋ฌธ์ œ ํ•ด๊ฒฐ - ProductReadCache ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์บ์‹œ ๊ตฌํ˜„ ์ถ”์ƒํ™” (Caffeine/Redis ์ „ํ™˜ ์šฉ์ด) - afterCommit ์‹œ์  ๋ฌดํšจํ™”๋กœ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ ์ „ stale ์บ์‹œ ๋ฌธ์ œ ํ•ด๊ฒฐ - ArchUnit ๊ทœ์น™์— Infrastructure/Config์˜ ApplicationService ์ง์ ‘ ์˜์กด ๊ธˆ์ง€ ์ถ”๊ฐ€ --- apps/commerce-api/build.gradle.kts | 3 + .../product/ProductQueryService.java | 5 + .../application/product/ProductReadCache.java | 9 ++ .../application/product/ProductReadModel.java | 27 ++++ .../java/com/loopers/config/CacheConfig.java | 25 ++++ .../product/CaffeineProductReadCache.java | 31 ++++ .../product/ProductQueryServiceImpl.java | 30 ++++ .../product/ProductRepositoryImpl.java | 47 +++++- .../api/product/ProductV1Controller.java | 12 +- .../interfaces/api/product/ProductV1Dto.java | 8 + .../java/com/loopers/ArchitectureTest.java | 16 +- .../product/CaffeineProductReadCacheTest.java | 132 +++++++++++++++++ .../product/ProductCacheIntegrationTest.java | 140 ++++++++++++++++++ .../product/ProductQueryServiceImplTest.java | 89 +++++++++++ 14 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadCache.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductReadModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductReadCache.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductReadCacheTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryServiceImplTest.java 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/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java new file mode 100644 index 000000000..893c8c513 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -0,0 +1,5 @@ +package com.loopers.application.product; + +public interface ProductQueryService { + ProductReadModel getById(Long id); +} 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/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..8a5d4c946 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductQueryServiceImpl implements ProductQueryService { + + private final ProductJpaRepository productJpaRepository; + private final ProductReadCache productReadCache; + + @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; + } +} 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..ee42ea53d 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,6 @@ package com.loopers.infrastructure.product; +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 +10,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 +22,13 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final ProductReadCache productReadCache; @Override public Product save(Product product) { - return productJpaRepository.save(product); + Product saved = productJpaRepository.save(product); + evictAfterCommit(saved.getId()); + return saved; } @Override @@ -58,16 +64,51 @@ 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); + } + } + ); + } else { + productReadCache.evict(id); + } + } + + private void evictAllAfterCommit() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + productReadCache.evictAll(); + } + } + ); + } else { + productReadCache.evictAll(); + } } private Sort toSort(ProductSortType sortType) { 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..b5eeb2078 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,8 +1,11 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.brand.BrandApplicationService; import com.loopers.application.product.ProductApplicationService; import com.loopers.application.product.ProductPageWithBrands; -import com.loopers.application.product.ProductWithBrand; +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadModel; +import com.loopers.domain.brand.Brand; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; @@ -18,6 +21,8 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductApplicationService productApplicationService; + private final ProductQueryService productQueryService; + private final BrandApplicationService brandApplicationService; @GetMapping @Override @@ -34,7 +39,8 @@ public ApiResponse getAll( @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..2199e521e 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,5 +1,6 @@ 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; @@ -23,6 +24,13 @@ public static ProductResponse from(Product product, Brand brand) { product.getPrice().amount(), product.getLikeCount() ); } + + public static ProductResponse from(ProductReadModel product, Brand brand) { + return new ProductResponse( + product.id(), product.brandId(), brand.getName(), product.name(), + product.price(), product.likeCount() + ); + } } public record ProductPageResponse( 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/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..fd9ed620d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java @@ -0,0 +1,140 @@ +package com.loopers.infrastructure.product; + +import com.github.benmanes.caffeine.cache.Cache; +import com.loopers.application.product.ProductQueryService; +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +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.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 Cache productDetailCache; + + private Long brandId; + + @BeforeEach + void setUp() { + productReadCache.evictAll(); + Brand brand = brandService.register("๋‚˜์ดํ‚ค"); + brandId = brand.getId(); + } + + @AfterEach + void tearDown() { + productReadCache.evictAll(); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("afterCommit ์บ์‹œ ๋ฌดํšจํ™”๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ, ") + @Nested + class AfterCommitEviction { + + @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(); + } + } +} 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..4590a746e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryServiceImplTest.java @@ -0,0 +1,89 @@ +package com.loopers.infrastructure.product; + +import com.loopers.application.product.ProductReadCache; +import com.loopers.application.product.ProductReadModel; +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 java.time.ZonedDateTime; +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.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class ProductQueryServiceImplTest { + + private ProductJpaRepository productJpaRepository; + private ProductReadCache productReadCache; + private ProductQueryServiceImpl productQueryService; + + @BeforeEach + void setUp() { + productJpaRepository = mock(ProductJpaRepository.class); + productReadCache = mock(ProductReadCache.class); + productQueryService = new ProductQueryServiceImpl(productJpaRepository, productReadCache); + } + + @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); + } + } +} From d1d555342bbdf0aaee1c5928b472c6457a311113 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Wed, 11 Mar 2026 21:27:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat=20:=20=EC=A0=84=EC=B2=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=BA=90=EC=8B=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=A0=84=EB=9E=B5?= =?UTF-8?q?=20=EC=A0=84=EC=A0=9C=20=EC=B6=A9=EC=A1=B1=20+=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=AC=B4=ED=9A=A8?= =?UTF-8?q?=ED=99=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductPageReadCache ์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ชฉ๋ก ์บ์‹œ ์ถ”์ƒํ™” - ์ „์ฒด ์กฐํšŒ(brandId=null)๋งŒ ์บ์‹œ, ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์กฐํšŒ๋Š” ์ธ๋ฑ์Šค๋กœ ์ปค๋ฒ„ - ์“ฐ๊ธฐ ์‹œ ์ƒ์„ธ ์บ์‹œ + ํŽ˜์ด์ง€ ์บ์‹œ ๋ชจ๋‘ afterCommit ๋ฌดํšจํ™” - V1 ์ปจํŠธ๋กค๋Ÿฌ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ProductQueryService๋กœ ์ „ํ™˜ - ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ evictAll ๊ฒฝ๋กœ ํšŒ๊ท€ ๋ฐฉ์ง€ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ --- .../product/ProductPageReadCache.java | 12 ++ .../product/ProductQueryService.java | 4 + .../product/CaffeineProductPageReadCache.java | 42 ++++++ .../product/ProductQueryServiceImpl.java | 43 ++++++ .../product/ProductRepositoryImpl.java | 6 + .../api/product/ProductV1Controller.java | 18 ++- .../interfaces/api/product/ProductV1Dto.java | 14 +- .../CaffeineProductPageReadCacheTest.java | 125 ++++++++++++++++++ .../product/ProductCacheIntegrationTest.java | 77 ++++++++++- .../product/ProductQueryServiceImplTest.java | 66 ++++++++- 10 files changed, 388 insertions(+), 19 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageReadCache.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/CaffeineProductPageReadCache.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java 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 index 893c8c513..6cb9447e5 100644 --- 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 @@ -1,5 +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/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/ProductQueryServiceImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java index 8a5d4c946..6b9976cff 100644 --- 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 @@ -1,11 +1,18 @@ 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 @@ -14,6 +21,7 @@ public class ProductQueryServiceImpl implements ProductQueryService { private final ProductJpaRepository productJpaRepository; private final ProductReadCache productReadCache; + private final ProductPageReadCache productPageReadCache; @Override public ProductReadModel getById(Long id) { @@ -27,4 +35,39 @@ public ProductReadModel getById(Long id) { } 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) { + return 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"); + }; + } } 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 ee42ea53d..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,6 @@ 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; @@ -23,6 +24,7 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; private final ProductReadCache productReadCache; + private final ProductPageReadCache productPageReadCache; @Override public Product save(Product product) { @@ -88,11 +90,13 @@ private void evictAfterCommit(Long id) { @Override public void afterCommit() { productReadCache.evict(id); + productPageReadCache.evictAll(); } } ); } else { productReadCache.evict(id); + productPageReadCache.evictAll(); } } @@ -103,11 +107,13 @@ private void evictAllAfterCommit() { @Override public void afterCommit() { productReadCache.evictAll(); + productPageReadCache.evictAll(); } } ); } else { productReadCache.evictAll(); + productPageReadCache.evictAll(); } } 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 b5eeb2078..9cd60a6d3 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,10 +1,9 @@ package com.loopers.interfaces.api.product; import com.loopers.application.brand.BrandApplicationService; -import com.loopers.application.product.ProductApplicationService; -import com.loopers.application.product.ProductPageWithBrands; 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; @@ -15,12 +14,15 @@ 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") public class ProductV1Controller implements ProductV1ApiSpec { - private final ProductApplicationService productApplicationService; private final ProductQueryService productQueryService; private final BrandApplicationService brandApplicationService; @@ -32,8 +34,14 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") 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}") 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 2199e521e..ad7655e1a 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 @@ -3,7 +3,6 @@ import com.loopers.application.product.ProductReadModel; import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; import java.util.List; import java.util.Map; @@ -18,13 +17,6 @@ public record ProductResponse( int price, int likeCount ) { - public static ProductResponse from(Product product, Brand brand) { - return new ProductResponse( - product.getId(), product.getBrandId(), brand.getName(), product.getName(), - product.getPrice().amount(), product.getLikeCount() - ); - } - public static ProductResponse from(ProductReadModel product, Brand brand) { return new ProductResponse( product.id(), product.brandId(), brand.getName(), product.name(), @@ -40,10 +32,10 @@ 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()))) + .filter(product -> brandMap.containsKey(product.brandId())) + .map(product -> ProductResponse.from(product, brandMap.get(product.brandId()))) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } 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..f41a83e67 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java @@ -0,0 +1,125 @@ +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("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/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductCacheIntegrationTest.java index fd9ed620d..be4b759ce 100644 --- 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 @@ -1,13 +1,16 @@ 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; @@ -41,6 +44,9 @@ class ProductCacheIntegrationTest { @Autowired private ProductReadCache productReadCache; + @Autowired + private ProductPageReadCache productPageReadCache; + @Autowired private Cache productDetailCache; @@ -49,6 +55,7 @@ class ProductCacheIntegrationTest { @BeforeEach void setUp() { productReadCache.evictAll(); + productPageReadCache.evictAll(); Brand brand = brandService.register("๋‚˜์ดํ‚ค"); brandId = brand.getId(); } @@ -56,12 +63,13 @@ void setUp() { @AfterEach void tearDown() { productReadCache.evictAll(); + productPageReadCache.evictAll(); databaseCleanUp.truncateAllTables(); } - @DisplayName("afterCommit ์บ์‹œ ๋ฌดํšจํ™”๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ, ") + @DisplayName("์ƒ์„ธ ์บ์‹œ afterCommit ๋ฌดํšจํ™”๋ฅผ ๊ฒ€์ฆํ•  ๋•Œ, ") @Nested - class AfterCommitEviction { + class DetailCacheEviction { @DisplayName("์ƒํ’ˆ ์ˆ˜์ • ์ปค๋ฐ‹ ํ›„, ์บ์‹œ๊ฐ€ ๋ฌดํšจํ™”๋˜์–ด ์ตœ์‹  ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test @@ -136,5 +144,70 @@ void evictsCache_afterProductDelete() { // 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 index 4590a746e..b5b441537 100644 --- 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 @@ -1,35 +1,50 @@ 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.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; 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); - productQueryService = new ProductQueryServiceImpl(productJpaRepository, productReadCache); + productPageReadCache = mock(ProductPageReadCache.class); + productQueryService = new ProductQueryServiceImpl( + productJpaRepository, productReadCache, productPageReadCache + ); } @DisplayName("getById๋ฅผ ํ˜ธ์ถœํ•  ๋•Œ, ") @@ -86,4 +101,53 @@ void preservesAllFields() { 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 + Page emptyPage = new PageImpl<>( + List.of(), PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")), 0 + ); + given(productJpaRepository.findAllByBrandIdAndDeletedAtIsNull( + eq(1L), any(PageRequest.class) + )).willReturn(emptyPage); + + // when + PageResult result = productQueryService.getAll( + 1L, ProductSortType.LATEST, 0, 20 + ); + + // then + assertThat(result.items()).isEmpty(); + verify(productPageReadCache, never()).get( + any(ProductSortType.class), anyInt(), anyInt(), any(Supplier.class) + ); + } + } } From 7dc936f380199cc2012910a45897934d56c91c2f Mon Sep 17 00:00:00 2001 From: yoonyootak Date: Thu, 12 Mar 2026 14:29:59 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor=20:=20Coderabbit=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=8C=80=EC=9D=91(=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D,=20=EC=A0=95=EB=A0=AC=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1,=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductQueryServiceImpl.java | 3 +- .../interfaces/api/ApiControllerAdvice.java | 11 ++++ .../api/product/ProductV1Controller.java | 7 ++- .../interfaces/api/product/ProductV1Dto.java | 11 +++- .../CaffeineProductPageReadCacheTest.java | 60 +++++++++++++++++++ .../product/ProductQueryServiceImplTest.java | 36 +++++++++-- 6 files changed, 119 insertions(+), 9 deletions(-) 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 index 6b9976cff..9f0c18896 100644 --- 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 @@ -64,10 +64,11 @@ private PageResult queryFromDb(Long brandId, ProductSortType s } private Sort toSort(ProductSortType sortType) { - return switch (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/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 9cd60a6d3..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 @@ -7,7 +7,9 @@ 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; @@ -21,6 +23,7 @@ @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/products") +@Validated public class ProductV1Controller implements ProductV1ApiSpec { private final ProductQueryService productQueryService; @@ -31,8 +34,8 @@ public class ProductV1Controller implements ProductV1ApiSpec { 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 ) { PageResult products = productQueryService.getAll( brandId, ProductSortType.from(sort), page, size 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 ad7655e1a..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 @@ -3,6 +3,8 @@ import com.loopers.application.product.ProductReadModel; import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; @@ -34,8 +36,13 @@ public record ProductPageResponse( ) { public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .filter(product -> brandMap.containsKey(product.brandId())) - .map(product -> ProductResponse.from(product, brandMap.get(product.brandId()))) + .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/infrastructure/product/CaffeineProductPageReadCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/CaffeineProductPageReadCacheTest.java index f41a83e67..b7658153d 100644 --- 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 @@ -97,6 +97,66 @@ void separatesCacheBySort() { 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์„ ํ˜ธ์ถœํ•  ๋•Œ, ") 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 index b5b441537..d5bbbc1e5 100644 --- 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 @@ -11,10 +11,12 @@ 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; @@ -131,12 +133,27 @@ void usesPageCache_whenBrandIdIsNull() { @Test void queriesDbDirectly_whenBrandIdIsNotNull() { // given - Page emptyPage = new PageImpl<>( - List.of(), PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")), 0 + 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(emptyPage); + )).willReturn(productPage); // when PageResult result = productQueryService.getAll( @@ -144,7 +161,18 @@ void queriesDbDirectly_whenBrandIdIsNotNull() { ); // then - assertThat(result.items()).isEmpty(); + 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) );