From 09fe32a0a215d2ee7975d8120f9e56debe47e8e1 Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:20:54 +0900 Subject: [PATCH 1/9] =?UTF-8?q?docs:=20=EA=B3=BC=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD,=20=EC=84=B1=EB=8A=A5=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D,=20=EA=B0=9C=EC=84=A0=20=EA=B3=84=ED=9A=8D,=20AS-IS?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=95=20=EA=B2=B0=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - round5-docs/00-requirements.md - round5-docs/01-performance-improvement-analysis.md - round5-docs/02-performance-improvement-plan.md - round5-docs/03-as-is-performance-measurement.md - round5-docs/03-as-is-performance-visualization.html Co-Authored-By: Claude Opus 4.6 --- round5-docs/00-requirements.md | 43 + .../01-performance-improvement-analysis.md | 404 +++++++ .../02-performance-improvement-plan.md | 1020 +++++++++++++++++ .../03-as-is-performance-measurement.md | 403 +++++++ .../03-as-is-performance-visualization.html | 725 ++++++++++++ 5 files changed, 2595 insertions(+) create mode 100644 round5-docs/00-requirements.md create mode 100644 round5-docs/01-performance-improvement-analysis.md create mode 100644 round5-docs/02-performance-improvement-plan.md create mode 100644 round5-docs/03-as-is-performance-measurement.md create mode 100644 round5-docs/03-as-is-performance-visualization.html diff --git a/round5-docs/00-requirements.md b/round5-docs/00-requirements.md new file mode 100644 index 000000000..a3e3ec699 --- /dev/null +++ b/round5-docs/00-requirements.md @@ -0,0 +1,43 @@ +### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด + +์•„๋ž˜ ์„ธ ๊ฐ€์ง€ **์„ฑ๋Šฅ ๊ฐœ์„ ์„ ์ˆ˜ํ–‰**ํ•ฉ๋‹ˆ๋‹ค. + +> ๋ชจ๋‘ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ข‹์Šต๋‹ˆ๋‹ค. ์„ ํƒ ์ด์œ  ๋ฐ AS-IS, TO-BE ์— ๋Œ€ํ•ด์„œ๋Š” ๋ธ”๋กœ๊ทธ์— ์ฒจ๋ถ€ํ•ด ์ฃผ์„ธ์š”. +> + +--- + +**โ‘  ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๋Šฅ ๊ฐœ์„ ** + +- ์ƒํ’ˆ ๋ฐ์ดํ„ฐ๋ฅผ 10๋งŒ๊ฐœ ์ด์ƒ ์ค€๋น„ํ•ฉ๋‹ˆ๋‹ค (๊ฐ ์ปฌ๋Ÿผ์˜ ๊ฐ’์€ ๋‹ค์–‘ํ•˜๊ฒŒ ๋ถ„ํฌํ•˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค ) +- ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๊ณ , **`EXPLAIN`** ๋ถ„์„์„ ํ†ตํ•ด ์ธ๋ฑ์Šค ์ตœ์ ํ™”๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. +- ์„ฑ๋Šฅ ๊ฐœ์„  ์ „ํ›„ ๋น„๊ต๋ฅผ ํฌํ•จํ•ด ์ฃผ์„ธ์š”. + +**โ‘ก ์ข‹์•„์š” ์ˆ˜ ์ •๋ ฌ ๊ตฌ์กฐ ๊ฐœ์„ ** + +- **๋น„์ •๊ทœํ™”**(**`like_count`**) ํ˜น์€ **MaterializedView** ์ค‘ ํ•˜๋‚˜๋ฅผ ์„ ํƒํ•˜์—ฌ ์ข‹์•„์š” ์ˆ˜ ์ •๋ ฌ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•ฉ๋‹ˆ๋‹ค. +- ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ count ๋™๊ธฐํ™” ์ฒ˜๋ฆฌ ๋ฐฉ์‹์ด ๋ˆ„๋ฝ๋˜์–ด ์žˆ๋‹ค๋ฉด ์ด ๋˜ํ•œ ํ•จ๊ป˜ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. + +**โ‘ข ์บ์‹œ ์ ์šฉ** + +- ์ƒํ’ˆ ์ƒ์„ธ API ๋ฐ ์ƒํ’ˆ ๋ชฉ๋ก API์— **Redis ์บ์‹œ**๋ฅผ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. +- TTL ์„ค์ •, ์บ์‹œ ํ‚ค ์„ค๊ณ„, ๋ฌดํšจํ™” ์ „๋žต ์ค‘ ํ•˜๋‚˜ ์ด์ƒ ํฌํ•จํ•ด ์ฃผ์„ธ์š”. + +--- + +## โœ… Checklist + +### ๐Ÿ”– Index + +- [ ] ์ƒํ’ˆ ๋ชฉ๋ก API์—์„œ brandId ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰, ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ ๋“ฑ์„ ์ฒ˜๋ฆฌํ–ˆ๋‹ค +- [ ] ์กฐํšŒ ํ•„ํ„ฐ, ์ •๋ ฌ ์กฐ๊ฑด๋ณ„ ์œ ์ฆˆ์ผ€์ด์Šค๋ฅผ ๋ถ„์„ํ•˜์—ฌ ์ธ๋ฑ์Šค๋ฅผ ์ ์šฉํ•˜๊ณ  ์ „ ํ›„ ์„ฑ๋Šฅ๋น„๊ต๋ฅผ ์ง„ํ–‰ํ–ˆ๋‹ค + +### โค๏ธ Structure + +- [ ] ์ƒํ’ˆ ๋ชฉ๋ก/์ƒ์„ธ ์กฐํšŒ ์‹œ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์กฐํšŒ ๋ฐ ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ์ด ๊ฐ€๋Šฅํ•˜๋„๋ก ๊ตฌ์กฐ ๊ฐœ์„ ์„ ์ง„ํ–‰ํ–ˆ๋‹ค +- [ ] ์ข‹์•„์š” ์ ์šฉ/ํ•ด์ œ ์ง„ํ–‰ ์‹œ ์ƒํ’ˆ ์ข‹์•„์š” ์ˆ˜ ๋˜ํ•œ ์ •์ƒ์ ์œผ๋กœ ๋™๊ธฐํ™”๋˜๋„๋ก ์ง„ํ–‰ํ•˜์˜€๋‹ค + +### โšก Cache + +- [ ] Redis ์บ์‹œ๋ฅผ ์ ์šฉํ•˜๊ณ  TTL ๋˜๋Š” ๋ฌดํšจํ™” ์ „๋žต์„ ์ ์šฉํ–ˆ๋‹ค +- [ ] ์บ์‹œ ๋ฏธ์Šค ์ƒํ™ฉ์—์„œ๋„ ์„œ๋น„์Šค๊ฐ€ ์ •์ƒ ๋™์ž‘ํ•˜๋„๋ก ์ฒ˜๋ฆฌํ–ˆ๋‹ค. diff --git a/round5-docs/01-performance-improvement-analysis.md b/round5-docs/01-performance-improvement-analysis.md new file mode 100644 index 000000000..aac6bf188 --- /dev/null +++ b/round5-docs/01-performance-improvement-analysis.md @@ -0,0 +1,404 @@ +# ์„ฑ๋Šฅ ๊ฐœ์„  ํ˜„ํ™ฉ ๋ถ„์„ + +## ๋ชฉํ‘œ โ‘  ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๋Šฅ ๊ฐœ์„  + +### ํ˜„์žฌ ์ƒํ™ฉ + +| ํ•ญ๋ชฉ | AS-IS | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (`GET /api/v1/products`) | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | `deleted_at IS NULL` + ์„ ํƒ์  `brand_id` ํ•„ํ„ฐ | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | `products` LEFT JOIN `brands` | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | **PK(id)๋งŒ ์กด์žฌ. brand_id, deleted_at, like_count, price, created_at ์— ์ธ๋ฑ์Šค ์—†์Œ** | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | ์—†์Œ | +| ์บ์‹œ ํ‚ค ์ „๋žต | N/A | +| ์บ์‹œ TTL ๊ฐ€์ • | N/A | + +**ํ˜„์žฌ ์ฟผ๋ฆฌ ๊ตฌ์กฐ** (`ProductQuerydslRepository.searchProducts`): +```sql +-- ๋ฐ์ดํ„ฐ ์ฟผ๋ฆฌ +SELECT p.id, p.brand_id, b.name, p.name, p.price, p.stock, p.like_count +FROM products p LEFT JOIN brands b ON b.id = p.brand_id +WHERE p.deleted_at IS NULL [AND p.brand_id = ?] +ORDER BY p.like_count DESC -- (or created_at DESC, price ASC) +OFFSET ? LIMIT ? + +-- ์นด์šดํŠธ ์ฟผ๋ฆฌ (๋ณ„๋„ ์‹คํ–‰) +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL [AND brand_id = ?] +``` + +**๋ฌธ์ œ์ **: +- 10๋งŒ๊ฑด ์ด์ƒ์—์„œ `deleted_at IS NULL` ํ•„ํ„ฐ โ†’ Full Table Scan +- `brand_id` ํ•„ํ„ฐ + `like_count DESC` ์ •๋ ฌ โ†’ ์ธ๋ฑ์Šค ์—†์ด filesort ๋ฐœ์ƒ +- ์นด์šดํŠธ ์ฟผ๋ฆฌ๋„ ๋ณ„๋„๋กœ Full Table Scan +- `OFFSET` ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜์€ ๋’ค ํŽ˜์ด์ง€๋กœ ๊ฐˆ์ˆ˜๋ก ์„ฑ๋Šฅ ๊ธ‰๊ฐ (skip scan) + +### ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„ ์กฐํšŒ ์กฐ๊ฑด ๋งคํŠธ๋ฆญ์Šค + +์ด 6๊ฐ€์ง€ ์กฐํ•ฉ์ด ๋ฐœ์ƒํ•˜๋ฉฐ, ๊ฐ๊ฐ์— ๋Œ€ํ•ด EXPLAIN ๋ถ„์„์ด ํ•„์š”ํ•˜๋‹ค. + +| # | brandId ํ•„ํ„ฐ | ์ •๋ ฌ ์กฐ๊ฑด | WHERE ์ ˆ | ORDER BY | +|---|:---:|---|---|---| +| 1 | X | LATEST (๊ธฐ๋ณธ) | `deleted_at IS NULL` | `created_at DESC` | +| 2 | X | PRICE_ASC | `deleted_at IS NULL` | `price ASC` | +| 3 | X | LIKES_DESC | `deleted_at IS NULL` | `like_count DESC` | +| 4 | O | LATEST | `deleted_at IS NULL AND brand_id = ?` | `created_at DESC` | +| 5 | O | PRICE_ASC | `deleted_at IS NULL AND brand_id = ?` | `price ASC` | +| 6 | O | LIKES_DESC | `deleted_at IS NULL AND brand_id = ?` | `like_count DESC` | + +> **EXPLAIN ๋ถ„์„ ๊ณ„ํš**: ์ธ๋ฑ์Šค ์ ์šฉ ์ „(AS-IS)๊ณผ ์ ์šฉ ํ›„(TO-BE) ๊ฐ 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค์— ๋Œ€ํ•ด `EXPLAIN ANALYZE`๋ฅผ ์‹คํ–‰ํ•˜๊ณ , `type`, `rows`, `Extra` (filesort/Using index ์—ฌ๋ถ€), ์‹คํ–‰ ์‹œ๊ฐ„์„ ๋น„๊ตํ•œ๋‹ค. + +### ์„ค๊ณ„ ๋ฐฉํ–ฅ + +| ํ•ญ๋ชฉ | TO-BE | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ๋™์ผ | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | ๋™์ผ (brand_id ํ•„ํ„ฐ + ์ •๋ ฌ) | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | ๋™์ผ | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ํ•„์š” โ€” ์ •๋ ฌ ํƒ€์ž…๋ณ„๋กœ covering ๊ฐ€๋Šฅํ•œ ์ธ๋ฑ์Šค ์„ค๊ณ„ | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | ๋ชฉํ‘œ โ‘ข์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌ | +| ์บ์‹œ ํ‚ค ์ „๋žต | ๋ชฉํ‘œ โ‘ข์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌ | +| ์บ์‹œ TTL ๊ฐ€์ • | ๋ชฉํ‘œ โ‘ข์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌ | + +**์ธ๋ฑ์Šค ํ›„๋ณด (EXPLAIN ๋ถ„์„ ๋Œ€์ƒ)**: + +| ์ธ๋ฑ์Šค | ์ปค๋ฒ„ํ•˜๋Š” ์œ ์ฆˆ์ผ€์ด์Šค | ๋น„๊ณ  | +|--------|:---:|---| +| `(brand_id, deleted_at, like_count)` | #3, #6 | ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ (ํ•ต์‹ฌ) | +| `(brand_id, deleted_at, created_at)` | #1, #4 | ์ตœ์‹ ์ˆœ ์ •๋ ฌ | +| `(brand_id, deleted_at, price)` | #2, #5 | ๊ฐ€๊ฒฉ์ˆœ ์ •๋ ฌ | + +> **์ปฌ๋Ÿผ ์ˆœ์„œ ๊ฒฐ์ • ์›์น™ โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ์šฐ์„ **: `brand_id`(์ˆ˜์‹ญ~์ˆ˜๋ฐฑ distinct)๊ฐ€ `deleted_at`(2 distinct: NULL/timestamp)๋ณด๋‹ค ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์œผ๋ฏ€๋กœ ์„ ๋‘ ์ปฌ๋Ÿผ์— ๋ฐฐ์น˜. ๋‘ ์ปฌ๋Ÿผ ๋ชจ๋‘ equality ์กฐ๊ฑด์œผ๋กœ ์‚ฌ์šฉ๋˜๋ฏ€๋กœ ์ธ๋ฑ์Šค ๋™์ž‘์—๋Š” ์˜ํ–ฅ ์—†์œผ๋‚˜, B-tree fan-out์ด ๋” ๊ท ๋“ฑํ•ด์ง. +> +> brandId ํ•„ํ„ฐ ์—†๋Š” ์ผ€์ด์Šค(#1, #2, #3)์—์„œ๋Š” ์ธ๋ฑ์Šค ์„ ๋‘ ์ปฌ๋Ÿผ(`brand_id`)์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ๋ณ„๋„์˜ 2-column ์ธ๋ฑ์Šค `(deleted_at, sort_col)` ํ•„์š”. + +### ๊ตฌ์กฐ์  ๋ฆฌ์Šคํฌ ๋ถ„์„ + +**1. ์„ค๊ณ„๊ฐ€ ์„ฑ๋ฆฝํ•˜๊ธฐ ์œ„ํ•ด ๋ฐ˜๋“œ์‹œ ์ฐธ์ด์–ด์•ผ ํ•˜๋Š” ์ „์ œ ์กฐ๊ฑด** +- `deleted_at IS NULL`์ธ ํ–‰์ด ์ „์ฒด์˜ ๋Œ€๋‹ค์ˆ˜(90%+)๋ผ๋ฉด ์ธ๋ฑ์Šค ํ•„ํ„ฐ๋ง ํšจ๊ณผ๊ฐ€ ์•ฝํ•ด์ง. Partial Index๊ฐ€ ์—†๋Š” MySQL์—์„œ๋Š” `deleted_at` ์ปฌ๋Ÿผ์„ ์ธ๋ฑ์Šค ์„ ๋‘์— ๋‘๋˜, `IS NULL` ์กฐ๊ฑด์ด ์ธ๋ฑ์Šค range scan์„ ํƒˆ ์ˆ˜ ์žˆ์–ด์•ผ ํ•จ +- `brand_id`์˜ cardinality๊ฐ€ ์ถฉ๋ถ„ํžˆ ๋†’์•„์•ผ ์ธ๋ฑ์Šค selectivity๊ฐ€ ์˜๋ฏธ ์žˆ์Œ. ๋ธŒ๋žœ๋“œ ์ˆ˜๊ฐ€ 10๊ฐœ ๋ฏธ๋งŒ์ด๋ฉด ์ธ๋ฑ์Šค ํšจ๊ณผ ๋ฏธ๋ฏธ +- ์ •๋ ฌ ํƒ€์ž…์ด 3๊ฐœ(LATEST, PRICE_ASC, LIKES_DESC)์ด๋ฏ€๋กœ ์ธ๋ฑ์Šค๋ฅผ 3๋ฒŒ ๋งŒ๋“ค๊ฑฐ๋‚˜ trade-off๋ฅผ ๊ฐ์ˆ˜ํ•ด์•ผ ํ•จ + +**2. ํŠธ๋ž˜ํ”ฝ 10๋ฐฐ ์ฆ๊ฐ€ ์‹œ ๊ฐ€์žฅ ๋จผ์ € ๋ณ‘๋ชฉ์ด ๋  ์ง€์ ** +- **์นด์šดํŠธ ์ฟผ๋ฆฌ**. ๋ฐ์ดํ„ฐ ์ฟผ๋ฆฌ๋Š” LIMIT์œผ๋กœ ์ œํ•œ๋˜์ง€๋งŒ `COUNT(*)`๋Š” ์กฐ๊ฑด์— ๋งž๋Š” ์ „์ฒด ํ–‰์„ ์Šค์บ”ํ•จ. 10๋งŒ๊ฑด์—์„œ 100๋งŒ๊ฑด์œผ๋กœ ์ฆ๊ฐ€ํ•˜๋ฉด ์นด์šดํŠธ ์ฟผ๋ฆฌ๊ฐ€ ์„ ํ˜•์ ์œผ๋กœ ๋А๋ ค์ง +- `OFFSET` ํŽ˜์ด์ง€๋„ค์ด์…˜์˜ ๋’ค ํŽ˜์ด์ง€ (offset=90000 โ†’ 9๋งŒ๊ฑด skip) + +**3. ์บ์‹œ ์ ์ค‘๋ฅ  30% ์ดํ•˜ ์‹œ ๋ฌธ์ œ** +- (๋ชฉํ‘œ โ‘ข๊ณผ ์—ฐ๊ด€) ์บ์‹œ miss ์‹œ๋งˆ๋‹ค ์ธ๋ฑ์Šค๋ฅผ ํƒ€์ง€ ๋ชปํ•˜๋Š” ์ฟผ๋ฆฌ๊ฐ€ DB์— ์งํ–‰. ์ธ๋ฑ์Šค ์ตœ์ ํ™”๊ฐ€ ์„ ํ–‰๋˜์ง€ ์•Š์œผ๋ฉด ์บ์‹œ miss = Full Table Scan์ด ๋˜์–ด DB ๋ถ€ํ•˜ ๊ธ‰์ฆ + +**4. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์ด ๊นจ์งˆ ์ˆ˜ ์žˆ๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค** +- **(a)** ๊ด€๋ฆฌ์ž๊ฐ€ ์ƒํ’ˆ์„ soft-deleteํ•œ ์งํ›„, ์บ์‹œ์— ๋‚จ์•„์žˆ๋Š” ๋ชฉ๋ก์— ์‚ญ์ œ๋œ ์ƒํ’ˆ์ด ํฌํ•จ๋จ (๋ชฉํ‘œ โ‘ข๊ณผ ๊ต์ฐจ) +- **(b)** ์ข‹์•„์š” ์ˆ˜ UPDATE์™€ ๋ชฉ๋ก ์กฐํšŒ๊ฐ€ ๋™์‹œ์— ๋ฐœ์ƒ ์‹œ, ์ •๋ ฌ ์ˆœ์„œ๊ฐ€ ์ผ์‹œ์ ์œผ๋กœ staleํ•œ `like_count` ๊ธฐ์ค€์œผ๋กœ ๋ฐ˜ํ™˜๋จ (dirty read ์ˆ˜์ค€์ด์ง€๋งŒ, ๋ชฉ๋ก์—์„œ๋Š” ํ—ˆ์šฉ ๊ฐ€๋Šฅํ•  ์ˆ˜ ์žˆ์Œ) + +**5. ๊ฐ€์žฅ ๋‚˜์ค‘๊นŒ์ง€ ๋ฏธ๋ฃฐ ์ˆ˜ ์žˆ๋Š” ๊ฐœ์„ ** +- `OFFSET` โ†’ cursor-based ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ „ํ™˜. ํ˜„์žฌ ํ”„๋ก ํŠธ๊ฐ€ page ๊ธฐ๋ฐ˜์ด๋ฉด ์ฆ‰์‹œ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€. ์ธ๋ฑ์Šค๋งŒ ์ถ”๊ฐ€ํ•ด๋„ 10๋งŒ๊ฑด ์ˆ˜์ค€์—์„œ๋Š” ์ถฉ๋ถ„ + +**6. ๊ฐ€์žฅ ๋จผ์ € ์†๋Œ€์•ผ ํ•  ์œ„ํ—˜ ์š”์†Œ** +- **์ธ๋ฑ์Šค ๋ถ€์žฌ ์ž์ฒด**. 10๋งŒ๊ฑด์—์„œ `ORDER BY like_count DESC` + `WHERE deleted_at IS NULL`์€ Full Table Scan + filesort๊ฐ€ ํ™•์ •์ . ์ด๊ฒƒ์ด ํ•ด๊ฒฐ๋˜์ง€ ์•Š์œผ๋ฉด ์บ์‹œ๋ฅผ ์ถ”๊ฐ€ํ•ด๋„ miss ์‹œ DB๊ฐ€ ๋ฒ„ํ‹ฐ์ง€ ๋ชปํ•จ + +--- + +## ๋ชฉํ‘œ โ‘ก ์ข‹์•„์š” ์ˆ˜ ์ •๋ ฌ ๊ตฌ์กฐ ๊ฐœ์„  + +### ํ˜„์žฌ ์ƒํ™ฉ + +| ํ•ญ๋ชฉ | AS-IS | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ์ƒํ’ˆ ๋ชฉ๋ก์˜ ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ (`LIKES_DESC`) | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | `ORDER BY like_count DESC` | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | `product_read_model.like_count` (๋น„์ •๊ทœํ™” ํ•„๋“œ, Read Model ํ…Œ์ด๋ธ”์— ์กด์žฌ) | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | **`like_count`์— ์ธ๋ฑ์Šค ์—†์Œ** | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | ์—†์Œ | +| ์บ์‹œ ํ‚ค ์ „๋žต | N/A | +| ์บ์‹œ TTL ๊ฐ€์ • | N/A | + +**ํ•ต์‹ฌ ๋ฐœ๊ฒฌ: ๋น„์ •๊ทœํ™”๋Š” ์ด๋ฏธ ์™„๋ฃŒ๋˜์–ด ์žˆ์Œ** +- `ProductReadModelEntity`์— `like_count` ์ปฌ๋Ÿผ์ด ์กด์žฌ (`product_read_model` ํ…Œ์ด๋ธ”) +- Read Model ์ƒ์„ฑ ์‹œ `likeCount = 0`์œผ๋กœ ์ดˆ๊ธฐํ™” +- ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ `ProductReadModelJpaRepository`์˜ JPQL๋กœ ์›์ž์  ์ฆ๊ฐ: + ```sql + UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount + 1 WHERE e.id = :id + UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount - 1 WHERE e.id = :id AND e.likeCount > 0 + ``` +- Cross-BC ํ๋ฆ„: `ProductLikeCommandFacade.createLike()` โ†’ ์ข‹์•„์š” ์ €์žฅ + `ProductLikeCountSyncer.increaseLikeCount()` โ†’ `ProductCommandFacade` โ†’ `ProductCommandService` โ†’ `ProductReadModelRepository` โ†’ JPQL UPDATE + +**๋™๊ธฐํ™” ์ฒ˜๋ฆฌ๋„ ์ด๋ฏธ ๊ตฌํ˜„๋จ**: +- ์ข‹์•„์š” ์ƒ์„ฑ ์‹œ: ๊ฐ™์€ `@Transactional` ๋‚ด์—์„œ ์ข‹์•„์š” INSERT + Read Model `like_count` INCREMENT ์‹คํ–‰ +- ์ข‹์•„์š” ์‚ญ์ œ ์‹œ: ๊ฐ™์€ `@Transactional` ๋‚ด์—์„œ ์ข‹์•„์š” DELETE + Read Model `like_count` DECREMENT ์‹คํ–‰ +- ์ƒํ’ˆ ์‚ญ์ œ ์‹œ: `ProductCommandFacade.deleteProduct()` โ†’ ์ข‹์•„์š” ์ „์ฒด ์‚ญ์ œ (cleanup) + +### ์„ค๊ณ„ ๋ฐฉํ–ฅ + +| ํ•ญ๋ชฉ | TO-BE | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ๋™์ผ | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | ๋™์ผ | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | ๋™์ผ (์ด๋ฏธ ๋น„์ •๊ทœํ™”๋จ) | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | `like_count` ํฌํ•จ ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ (๋ชฉํ‘œ โ‘ ๊ณผ ๋ณ‘ํ•ฉ) | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | ๋ชฉํ‘œ โ‘ข์—์„œ ์ฒ˜๋ฆฌ | +| ์บ์‹œ ํ‚ค ์ „๋žต | N/A | +| ์บ์‹œ TTL ๊ฐ€์ • | N/A | + +**TO-BE์—์„œ ์ถ”๊ฐ€๋กœ ํ•„์š”ํ•œ ๊ฒƒ**: +- ๋น„์ •๊ทœํ™” ๊ตฌ์กฐ ์ž์ฒด๋Š” ์™„์„ฑ. ๋‚จ์€ ๊ฒƒ์€ **์ธ๋ฑ์Šค ์ถ”๊ฐ€** (๋ชฉํ‘œ โ‘ ๊ณผ ํ•ฉ๋ฅ˜) +- `EXPLAIN` ์ „ํ›„ ๋น„๊ต๋ฅผ ์œ„ํ•œ 10๋งŒ๊ฑด ๋ฐ์ดํ„ฐ ์ค€๋น„ +- ํ˜„์žฌ `like_count`์˜ ๋™๊ธฐํ™” gap ์‹œ๋‚˜๋ฆฌ์˜ค ์ •๋ฆฌ (๋ธ”๋กœ๊ทธ์šฉ) + +### ๊ตฌ์กฐ์  ๋ฆฌ์Šคํฌ ๋ถ„์„ + +**1. ์„ค๊ณ„๊ฐ€ ์„ฑ๋ฆฝํ•˜๊ธฐ ์œ„ํ•ด ๋ฐ˜๋“œ์‹œ ์ฐธ์ด์–ด์•ผ ํ•˜๋Š” ์ „์ œ ์กฐ๊ฑด** +- ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ์™€ `like_count` ์ฆ๊ฐ์ด **๋ฐ˜๋“œ์‹œ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜** ์•ˆ์—์„œ ์‹คํ–‰๋˜์–ด์•ผ ํ•จ. ํ˜„์žฌ `ProductLikeCommandFacade`๊ฐ€ `@Transactional`์ด๋ฏ€๋กœ ์„ฑ๋ฆฝ. ๋งŒ์•ฝ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ๋น„๋™๊ธฐ๋กœ ์ „ํ™˜ํ•˜๋ฉด ์ด ์ „์ œ๊ฐ€ ๊นจ์ง +- `likes` ํ…Œ์ด๋ธ”๊ณผ `products.like_count` ์‚ฌ์ด์— DB-level ์ œ์•ฝ(trigger ๋“ฑ)์ด ์—†์œผ๋ฏ€๋กœ, **์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ์ด์–ด๊ฐ€ ์œ ์ผํ•œ ๋™๊ธฐํ™” ๋ณด์žฅ ์ˆ˜๋‹จ** + +**2. ํŠธ๋ž˜ํ”ฝ 10๋ฐฐ ์ฆ๊ฐ€ ์‹œ ๊ฐ€์žฅ ๋จผ์ € ๋ณ‘๋ชฉ์ด ๋  ์ง€์ ** +- ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ์˜ `UPDATE products SET like_count = like_count + 1 WHERE id = ?`๋Š” ํ•ด๋‹น row์— ๋Œ€ํ•œ **exclusive row lock**์„ ์žก์Œ. ํŠน์ • ์ธ๊ธฐ ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ๋ชฐ๋ฆฌ๋ฉด(hotspot) ํ•ด๋‹น row์— lock contention ๋ฐœ์ƒ +- ํ˜„์žฌ `@Transactional`์ด Facade ๋ ˆ๋ฒจ์ด๋ฏ€๋กœ, ์ข‹์•„์š” INSERT + ์นด์šดํŠธ UPDATE + ์ƒํ’ˆ ์กด์žฌ ๊ฒ€์ฆ๊นŒ์ง€ ํ•˜๋‚˜์˜ TX์— ๋ฌถ์—ฌ lock ์œ ์ง€ ์‹œ๊ฐ„์ด ๊ธธ์–ด์งˆ ์ˆ˜ ์žˆ์Œ + +**3. ์บ์‹œ ์ ์ค‘๋ฅ  30% ์ดํ•˜ ์‹œ ๋ฌธ์ œ** +- `like_count` ์ž์ฒด๋Š” products ํ…Œ์ด๋ธ” row์— ์žˆ์œผ๋ฏ€๋กœ ๋ชฉ๋ก ์กฐํšŒ ์‹œ ์ถ”๊ฐ€ JOIN ๋ถˆํ•„์š” (์ด๋ฏธ ํ•ด๊ฒฐ๋จ). ์บ์‹œ miss๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ์ธ๋ฑ์Šค๋งŒ ํƒ€๋ฉด DB ๋ถ€ํ•˜๋Š” ์ œํ•œ์  +- ๋‹ค๋งŒ ์ธ๊ธฐ ์ƒํ’ˆ์˜ like_count๊ฐ€ ๋นˆ๋ฒˆํžˆ ๋ณ€๊ฒฝ๋˜๋ฉด ์บ์‹œ ๋ฌดํšจํ™” ๋นˆ๋„๊ฐ€ ๋†’์•„์ ธ ์ ์ค‘๋ฅ  ์ž์ฒด๊ฐ€ ๋‚ฎ์•„์ง€๋Š” ์•…์ˆœํ™˜ ๊ฐ€๋Šฅ + +**4. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์ด ๊นจ์งˆ ์ˆ˜ ์žˆ๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค** +- **(a) ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋น„์ •์ƒ ์ข…๋ฃŒ**: ์ข‹์•„์š” INSERT๋Š” ์ปค๋ฐ‹๋˜์—ˆ์œผ๋‚˜ `like_count` UPDATE ์ „์— ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ฃฝ๋Š” ๊ฒฝ์šฐ โ€” ํ˜„์žฌ ๊ฐ™์€ TX์ด๋ฏ€๋กœ ๋‘˜ ๋‹ค ๋กค๋ฐฑ๋˜์–ด ์•ˆ์ „. ๊ทธ๋Ÿฌ๋‚˜ **TX ๋ถ„๋ฆฌ๋ฅผ ๋„์ž…ํ•˜๋ฉด ๋ฐœ์ƒ ๊ฐ€๋Šฅ** +- **(b) ์ˆ˜๋™ DB ์กฐ์ž‘**: DBA๊ฐ€ `likes` ํ…Œ์ด๋ธ”์—์„œ ์ง์ ‘ row๋ฅผ ์‚ญ์ œํ•˜๋ฉด `products.like_count`์™€ ์‹ค์ œ `COUNT(*)` ๋ถˆ์ผ์น˜ ๋ฐœ์ƒ. ๋ณด์ • ๋ฐฐ์น˜(reconciliation)๊ฐ€ ์—†์Œ +- **(c) ์ƒํ’ˆ ์‚ญ์ œ ํ›„ ๋ณต์›**: soft-delete๋œ ์ƒํ’ˆ์„ `restore()`ํ•  ๊ฒฝ์šฐ, ์‚ญ์ œ ์‹œ ์ข‹์•„์š”๊ฐ€ ์ผ๊ด„ hard-delete(`deleteAllByTargetId`)๋˜์—ˆ์œผ๋ฏ€๋กœ ๋ณต์› ํ›„ `like_count`๊ฐ€ ์‹ค์ œ 0์ด์–ด์•ผ ํ•˜๋‚˜, `like_count` ํ•„๋“œ๊ฐ€ ์ด์ „ ๊ฐ’์„ ์œ ์ง€ํ•  ์ˆ˜ ์žˆ์Œ (ํ˜„์žฌ restore ์‹œ like_count ๋ฆฌ์…‹ ๋กœ์ง ๋ฏธํ™•์ธ) + +**5. ๊ฐ€์žฅ ๋‚˜์ค‘๊นŒ์ง€ ๋ฏธ๋ฃฐ ์ˆ˜ ์žˆ๋Š” ๊ฐœ์„ ** +- `like_count` ๋ณด์ • ๋ฐฐ์น˜ (reconciliation cron). ํ˜„์žฌ ๋™๊ธฐ TX ๋ณด์žฅ์ด ๋˜๋ฏ€๋กœ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ ๋ฐœ์ƒ ํ™•๋ฅ ์ด ๋งค์šฐ ๋‚ฎ์•„ ๋ฏธ๋ฃฐ ์ˆ˜ ์žˆ์Œ + +**6. ๊ฐ€์žฅ ๋จผ์ € ์†๋Œ€์•ผ ํ•  ์œ„ํ—˜ ์š”์†Œ** +- **hotspot ์ƒํ’ˆ์˜ row lock contention**. ์ธ๊ธฐ ์ƒํ’ˆ์— ๋™์‹œ ์ข‹์•„์š” 100๊ฑด์ด ๋ชฐ๋ฆฌ๋ฉด `UPDATE ... WHERE id = ?`์˜ InnoDB row lock์ด ์ง๋ ฌํ™”๋จ. ํ˜„์žฌ๋Š” ๋ณ„๋„ ๋Œ€์‘ ์—†์Œ (Redis counter ๋ฒ„ํผ๋ง, ๋น„๋™๊ธฐ ํ•ฉ์‚ฐ ๋“ฑ ๋ฏธ์ ์šฉ) + +--- + +## ๋ชฉํ‘œ โ‘ข ์บ์‹œ ์ ์šฉ + +> ์ด ์ ˆ์€ ์ดˆ๊ธฐ ์บ์‹œ ์„ค๊ณ„ ํƒ์ƒ‰ ๊ธฐ๋ก์„ ํฌํ•จํ•œ๋‹ค. ํ˜„์žฌ ๊ตฌํ˜„์˜ ์ตœ์ข… ์ƒํƒœ๋Š” `2-Layer Cache(product:v1 / products:ids:v1)` + `์ƒ์„ธ 2๋ถ„ / ID ๋ฆฌ์ŠคํŠธ 3๋ถ„ TTL` + `targeted write-through`์ด๋ฉฐ, ์ƒ์„ธ ๋‚ด์šฉ์€ `05`, `06`, `07` ๋ฌธ์„œ๋ฅผ ๋”ฐ๋ฅธ๋‹ค. + +### ํ˜„์žฌ ์ƒํ™ฉ + +| ํ•ญ๋ชฉ | AS-IS | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ์ƒํ’ˆ ์ƒ์„ธ (`GET /api/v1/products/{id}`), ์ƒํ’ˆ ๋ชฉ๋ก (`GET /api/v1/products`) | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | ์ƒ์„ธ: PK ์กฐํšŒ, ๋ชฉ๋ก: brandId + sortType + page + size | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | `products` + `brands` (LEFT JOIN) | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | PK๋งŒ ์กด์žฌ | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | **์—†์Œ**. Spring Cache ๋ฏธํ™œ์„ฑํ™”. `@EnableCaching` ์—†์Œ. `CacheManager` ๋นˆ ์—†์Œ | +| ์บ์‹œ ํ‚ค ์ „๋žต | N/A | +| ์บ์‹œ TTL ๊ฐ€์ • | N/A | + +**Redis ์ธํ”„๋ผ ํ˜„ํ™ฉ**: +- `modules/redis/` ๋ชจ๋“ˆ ์กด์žฌ, `spring-boot-starter-data-redis` ์˜์กด์„ฑ ์žˆ์Œ +- `RedisConfig`์—์„œ Lettuce ๊ธฐ๋ฐ˜ master-replica ๊ตฌ์„ฑ ์™„๋ฃŒ +- `RedisTemplate` ๋นˆ 2๊ฐœ (replica-preferred / master-only) +- **๊ทธ๋Ÿฌ๋‚˜ ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์—์„œ Redis๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ณณ์ด ์—†์Œ** +- ์œ ์ผํ•œ ์บ์‹œ๋Š” `CaffeineCouponIssueDuplicateGuard` (๋กœ์ปฌ ์ธ๋ฉ”๋ชจ๋ฆฌ, ์ฟ ํฐ ์ „์šฉ) + +### ์„ค๊ณ„ ๋ฐฉํ–ฅ + +| ํ•ญ๋ชฉ | TO-BE | +|------|-------| +| ์กฐํšŒ API ๋ชฉ์  | ๋™์ผ | +| ์ฃผ์š” ์กฐํšŒ ์กฐ๊ฑด | ๋™์ผ | +| ์‚ฌ์šฉํ•œ ํ…Œ์ด๋ธ”/๋ฐ์ดํ„ฐ | ๋™์ผ | +| ํ•ด๋‹น ํ…Œ์ด๋ธ”์˜ ์ธ๋ฑ์Šค | ๋ชฉํ‘œ โ‘ ์—์„œ ์ถ”๊ฐ€ | +| ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€ ๋ฐ ์œ„์น˜ | **์ƒํ’ˆ ์ƒ์„ธ**: Facade ๋ ˆ๋ฒจ (Product+Brand ์กฐํ•ฉ). **์ƒํ’ˆ ๋ชฉ๋ก**: Service ๋ ˆ๋ฒจ (๋‹จ์ผ ๋„๋ฉ”์ธ) | +| ์บ์‹œ ํ‚ค ์ „๋žต | **์ƒ์„ธ**: `product:v1:{productId}`. **๋ชฉ๋ก ID ๋ฆฌ์ŠคํŠธ**: `products:ids:v1:{brandId\|all}:{sortType}:{page}:{size}` | +| ์บ์‹œ TTL | **์ƒ์„ธ**: 2๋ถ„. **ID ๋ฆฌ์ŠคํŠธ**: 3๋ถ„ | +| ๊ตฌํ˜„ ๋ฐฉ์‹ | RedisTemplate ์ง์ ‘ ์‚ฌ์šฉ (์บ์‹œ ํ๋ฆ„ ๋ช…์‹œ์  ์ œ์–ด) | + +#### ์„ค๊ณ„ ๊ฒฐ์ • ์ƒ์„ธ + +##### (1) ์บ์‹œ ์ ์šฉ ๋ ˆ์ด์–ด โ€” API๋ณ„ ๋„๋ฉ”์ธ ์กฐํ•ฉ ๋ถ„์„ + +์บ์‹œ ๋ ˆ์ด์–ด๋Š” "๋ฌด์—‡์„ ์บ์‹ฑํ•˜๋Š”๊ฐ€"์— ๋”ฐ๋ผ ๊ฒฐ์ •ํ•œ๋‹ค. ๋‹จ์ผ ๋„๋ฉ”์ธ ๋ฐ์ดํ„ฐ๋ผ๋ฉด Service, ์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ์„ ์กฐํ•ฉํ•œ ๊ฒฐ๊ณผ๋ผ๋ฉด Facade์—์„œ ์บ์‹ฑํ•œ๋‹ค. + +| API | Facade ๋‚ด๋ถ€ ํ˜ธ์ถœ | ๋„๋ฉ”์ธ ์ˆ˜ | ์บ์‹œ ๋ ˆ์ด์–ด | ๊ทผ๊ฑฐ | +|-----|-----------------|:---------:|:----------:|------| +| `getProduct(id)` ์ƒํ’ˆ ์ƒ์„ธ | `ProductQueryService.findActiveById()` + `BrandQueryService.getBrandById()` | 2๊ฐœ | **Facade** | Product + Brand ์ด๋ฆ„์„ ์กฐํ•ฉํ•˜์—ฌ `ProductDetailOutDto` ์ƒ์„ฑ. Service ๋ ˆ๋ฒจ์—์„œ๋Š” ์™„์„ฑ๋œ ๊ฒฐ๊ณผ๋ฅผ ์บ์‹ฑํ•  ์ˆ˜ ์—†์Œ | +| `getProducts(...)` ์ƒํ’ˆ ๋ชฉ๋ก | `ProductQueryService.searchProducts()` ๋งŒ ํ˜ธ์ถœ | 1๊ฐœ | **Service** | QueryDSL LEFT JOIN์œผ๋กœ brand name ํฌํ•จํ•œ `ProductPageOutDto`๋ฅผ ํ•œ ๋ฒˆ์— ๋ฐ˜ํ™˜. Facade๋Š” ๋‹จ์ˆœ ์œ„์ž„๋งŒ ์ˆ˜ํ–‰ | + +##### (2) TTL ์„ค์ • ๊ทผ๊ฑฐ + +**TTL ๊ฒฐ์ • ์‹œ ํŒ๋‹จ ๊ธฐ์ค€ (์—…๊ณ„ ๊ณตํ†ต, ์šฐ์„ ์ˆœ์œ„์ˆœ)**: + +| ์ˆœ์œ„ | ๊ธฐ์ค€ | ์„ค๋ช… | +|:---:|------|------| +| 1 | **๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ๋นˆ๋„** | ์–ผ๋งˆ๋‚˜ ์ž์ฃผ ๋ฐ”๋€Œ๋Š”๊ฐ€? (๊ฐ€์žฅ ์ค‘์š”) | +| 2 | **ํ—ˆ์šฉ ๊ฐ€๋Šฅํ•œ staleness** | ๋น„์ฆˆ๋‹ˆ์Šค์ ์œผ๋กœ ๋ช‡ ๋ถ„ ์ „ ๋ฐ์ดํ„ฐ๊นŒ์ง€ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค˜๋„ ๊ดœ์ฐฎ์€๊ฐ€? | +| 3 | **์ฟผ๋ฆฌ ๋น„์šฉ** | cache miss ์‹œ DB ์ฟผ๋ฆฌ๊ฐ€ ์–ผ๋งˆ๋‚˜ ๋ฌด๊ฑฐ์šด๊ฐ€? (๋ฌด๊ฑฐ์šธ์ˆ˜๋ก ๊ธด TTL) | +| 4 | **ํŠธ๋ž˜ํ”ฝ ๋ณผ๋ฅจ** | ํŠธ๋ž˜ํ”ฝ์ด ๋†’์„์ˆ˜๋ก ๊ธด TTL๋กœ DB ๋ณดํ˜ธ | +| 5 | **๋ฉ”๋ชจ๋ฆฌ ์ œ์•ฝ** | ๊ธด TTL = ๋” ๋งŽ์€ ํ‚ค = Redis ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ฆ๊ฐ€ | + +> ์ถœ์ฒ˜: AWS Database Caching Strategies, ByteByteGo, Redis ๊ณต์‹ ๋ธ”๋กœ๊ทธ, ์˜ฌ๋ฆฌ๋ธŒ์˜ ํ…Œํฌ๋ธ”๋กœ๊ทธ + +**์—…๊ณ„ ์ผ๋ฐ˜์  TTL ์ฐธ๊ณ ๊ฐ’**: + +| ๋ฐ์ดํ„ฐ ์œ ํ˜• | ์ผ๋ฐ˜์  TTL | ์ถœ์ฒ˜ | +|------------|-----------|------| +| ์ƒํ’ˆ ์ƒ์„ธ (์ด๋ฆ„, ์„ค๋ช…) | 5~15๋ถ„ | AWS, ByteByteGo | +| ์ƒํ’ˆ ๋ชฉ๋ก / ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ | 5~15๋ถ„ | Redis ๊ณต์‹, Medium | +| ์นดํ…Œ๊ณ ๋ฆฌ / ๋ธŒ๋žœ๋“œ ์ •๋ณด | 30๋ถ„~12์‹œ๊ฐ„ | Microsoft Dynamics 365 | +| ์žฌ๊ณ  / ๊ฐ€๊ฒฉ | 0~5์ดˆ ๋˜๋Š” ์บ์‹œ ์•ˆํ•จ | Amazon ์‚ฌ๋ก€ | + +**ํ˜„์žฌ ๊ตฌํ˜„ TTL ๊ฒฐ์ •**: + +| ๋Œ€์ƒ | TTL | ํŒ๋‹จ ๊ทผ๊ฑฐ | +|------|:---:|----------| +| **์ƒํ’ˆ ์ƒ์„ธ** | **2๋ถ„** | ์ข‹์•„์š”/์žฌ๊ณ  ๋ณ€๊ฒฝ ์‹œ ์ƒ์„ธ write-through๊ฐ€ ์ž์ฃผ ๋ฐœ์ƒํ•˜๋ฏ€๋กœ TTL์€ ์งง๊ฒŒ ๋‘๊ณ , write-through ์‹คํŒจ ์‹œ ์ตœ๋Œ€ stale window๋งŒ ์ œํ•œ | +| **ID ๋ฆฌ์ŠคํŠธ** | **3๋ถ„** | ์ •๋ ฌ/ํ•„ํ„ฐ ์กฐํ•ฉ ํ‚ค ์ˆ˜๊ฐ€ ๋งŽ์•„ ๋น ๋ฅธ ๋ฉ”๋ชจ๋ฆฌ ํšŒ์ˆ˜๊ฐ€ ํ•„์š”ํ•˜๊ณ , ์ผ๋ถ€ ๋ชฉ๋ก stale์€ trade-off๋กœ ํ—ˆ์šฉ | + +> ํ˜„์žฌ ๊ตฌํ˜„์€ ์‹ค์ธก ํ›„ ์ƒ์„ธ 2๋ถ„ / ID ๋ฆฌ์ŠคํŠธ 3๋ถ„์œผ๋กœ ๊ณ ์ •ํ–ˆ๋‹ค. `afterCommit` ์ด๋ฒคํŠธ ์ „ํ™˜๊ณผ TTL ์žฌ์กฐ์ •์€ ํ›„์† TODO๋‹ค. + +##### (3) ๋ฌดํšจํ™” ์ „๋žต โ€” Active Invalidation + Safety-Net TTL ๋ณ‘ํ–‰ + +์—…๊ณ„ ํ‘œ์ค€์€ **๋‘˜ ๋‹ค ์“ฐ๋Š” ๊ฒƒ**(defense-in-depth)์ด๋‹ค. AWS, Netflix EVCache, Meta TAO ๋ชจ๋‘ ์ด ์กฐํ•ฉ์„ ์‚ฌ์šฉํ•œ๋‹ค. + +| ์—ญํ•  | ๋ฉ”์ปค๋‹ˆ์ฆ˜ | ์„ค๋ช… | +|------|---------|------| +| **์ฆ‰์‹œ ๋ฌดํšจํ™”** | ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ `DEL key` | ์ƒํ’ˆ ์ˆ˜์ •/์‚ญ์ œ/์ข‹์•„์š” ๋ณ€๊ฒฝ ์‹œ ํ•ด๋‹น ์บ์‹œ ํ‚ค๋ฅผ ์ฆ‰์‹œ ์‚ญ์ œ. ์ •ํ•ฉ์„ฑ ์šฐ์„  | +| **์•ˆ์ „๋ง TTL** | ํ‚ค ์ƒ์„ฑ ์‹œ TTL ์„ค์ • | evict ์‹คํŒจ/๋ˆ„๋ฝ ์‹œ ์ตœ๋Œ€ staleness๋ฅผ TTL๋กœ ๋ณด์žฅ. ์กฐํšŒ๊ฐ€ ์ ์€ ํ‚ค์˜ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ž๋™ ํ•ด์ œ | + +**ํ˜„์žฌ ๊ตฌํ˜„์˜ ์บ์‹œ ๊ฐฑ์‹  ํŠธ๋ฆฌ๊ฑฐ**: +- **์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ** (`product:v1:{id}`): ์ƒ์„ฑ/์ˆ˜์ •/์ข‹์•„์š”/์žฌ๊ณ /๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ write-through, ์‚ญ์ œ ์‹œ evict +- **ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ** (`products:ids:v1:*`): ์ƒ์„ฑ/์‚ญ์ œ๋Š” ๋ชจ๋“  ์ •๋ ฌ, ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ์€ `PRICE_ASC`๋งŒ targeted refresh +- **์ข‹์•„์š” ๋ณ€๊ฒฝ**: ์ƒ์„ธ๋งŒ write-through, ID ๋ฆฌ์ŠคํŠธ๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ ํ—ˆ์šฉ + +> **์™œ "delete"์ด์ง€ "update"๊ฐ€ ์•„๋‹Œ๊ฐ€?** ์บ์‹œ ๊ฐ’์„ ์ง์ ‘ ๊ฐฑ์‹ ํ•˜๋ฉด ๋‘ ๊ฐœ์˜ ๋™์‹œ ์“ฐ๊ธฐ๊ฐ€ race condition์„ ์ผ์œผํ‚ฌ ์ˆ˜ ์žˆ๋‹ค. ์‚ญ์ œ ํ›„ ๋‹ค์Œ ์กฐํšŒ ์‹œ DB์—์„œ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋ฅผ lazy loadํ•˜๋Š” ๊ฒƒ์ด ๋” ์•ˆ์ „ํ•˜๋‹ค. (์ถœ์ฒ˜: AWS Cache-Aside Pattern, Redis ๊ณต์‹) + +##### (4) thundering herd ๋ฐฉ์–ด โ€” ํ˜„์žฌ ๊ตฌํ˜„ + +| ๊ณ„์ธต | ์ „๋žต | ํ˜„์žฌ ์ƒํƒœ | +|:---:|------|----------| +| 1 | **TTL jitter** | ์ ์šฉ | +| 2 | **PER** | ์ ์šฉ | +| 3 | **LocalCacheLock + double-check** | ์ ์šฉ | + +> ๋ถ„์‚ฐ ๋ฝ(`RedisCacheLock`)์€ ๊ตฌํ˜„์ฒด๋กœ๋งŒ ๋‚จ์•„ ์žˆ๊ณ , ํ˜„์žฌ ๋Ÿฐํƒ€์ž„ ๊ฒฝ๋กœ๋Š” `LocalCacheLock`์ด `@Primary`๋‹ค. + +##### (5) ๊ตฌํ˜„ ๋ฐฉ์‹ โ€” RedisTemplate ์ง์ ‘ ์‚ฌ์šฉ + +`@Cacheable` ๋Œ€์‹  `RedisTemplate`์„ ์ง์ ‘ ์‚ฌ์šฉํ•œ๋‹ค. + +| ํŒ๋‹จ ๊ธฐ์ค€ | ๊ฒฐ์ • ๊ทผ๊ฑฐ | +|----------|----------| +| ์บ์‹œ ํ๋ฆ„ ๊ฐ€์‹œ์„ฑ | ์บ์‹œ ์ €์žฅ/์กฐํšŒ/์‚ญ์ œ ์‹œ์ ์ด ์ฝ”๋“œ์—์„œ ๋ช…์‹œ์ ์œผ๋กœ ๋ณด์—ฌ์•ผ ํ•จ | +| fallback ์ œ์–ด | Redis ์žฅ์•  ์‹œ `try-catch`๋กœ DB fallback์„ ์ง์ ‘ ๊ตฌํ˜„ํ•ด์•ผ ํ•จ. `@Cacheable`์€ ์˜ˆ์™ธ ์ „ํŒŒ ์ œ์–ด๊ฐ€ ์ œํ•œ์  | +| TTL ์„ธ๋ฐ€ํ•œ ์ œ์–ด | API๋ณ„๋กœ ๋‹ค๋ฅธ TTL(์ƒ์„ธ 2๋ถ„, ID ๋ฆฌ์ŠคํŠธ 3๋ถ„) + jitter๋ฅผ ์ง์ ‘ ์ ์šฉ | +| ๊ณผ์ œ ํ•™์Šต์ž๋ฃŒ ๊ถŒ์žฅ | "์บ์‹œ๊ฐ€ ์–ธ์ œ ์ €์žฅ๋˜๊ณ  ์–ธ์ œ ๋ฌดํšจํ™”๋˜๋Š”์ง€๋ฅผ ์ •ํ™•ํžˆ ์•Œ์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค" โ€” RedisTemplate ์‹ค์Šต ์ถ”์ฒœ | + +> ํ˜„์žฌ ๊ตฌํ˜„์€ `RedisTemplate` + `ObjectMapper` ์ง๋ ฌํ™” ์กฐํ•ฉ์„ ์‚ฌ์šฉํ•œ๋‹ค. + +**์บ์‹œ ๋ฏธ์Šค ์‹œ ์ •์ƒ ๋™์ž‘ ์ „๋žต (Cache-Aside + Fallback)**: +- **์ผ๋ฐ˜ ์บ์‹œ ๋ฏธ์Šค**: Cache-Aside ํŒจํ„ด ์ ์šฉ. ์บ์‹œ์— ์—†์œผ๋ฉด DB ์กฐํšŒ โ†’ ๊ฒฐ๊ณผ๋ฅผ ์บ์‹œ์— ์ ์žฌ โ†’ ์‘๋‹ต ๋ฐ˜ํ™˜. ์ธ๋ฑ์Šค ์ตœ์ ํ™”(๋ชฉํ‘œ โ‘ )๊ฐ€ ์„ ํ–‰๋˜๋ฏ€๋กœ DB ์ง์ ‘ ์กฐํšŒ ์‹œ์—๋„ ์ˆ˜์šฉ ๊ฐ€๋Šฅํ•œ ์‘๋‹ต ์‹œ๊ฐ„ ๋ณด์žฅ +- **Redis ์žฅ์•  (์—ฐ๊ฒฐ ๋ถˆ๊ฐ€/ํƒ€์ž„์•„์›ƒ)**: Redis ํ˜ธ์ถœ์„ `try-catch`๋กœ ๊ฐ์‹ธ์„œ ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์บ์‹œ๋ฅผ skipํ•˜๊ณ  DB์—์„œ ์ง์ ‘ ์กฐํšŒ. Redis ์žฅ์• ๊ฐ€ ์„œ๋น„์Šค ์žฅ์• ๋กœ ์ „ํŒŒ๋˜์ง€ ์•Š๋„๋ก ๊ฒฉ๋ฆฌ (Redis๋Š” ์„ฑ๋Šฅ ๊ฐœ์„  ์ˆ˜๋‹จ์ด์ง€ ํ•„์ˆ˜ ์˜์กด์„ฑ์ด ์•„๋‹˜) + +### ๊ตฌ์กฐ์  ๋ฆฌ์Šคํฌ ๋ถ„์„ + +**1. ์„ค๊ณ„๊ฐ€ ์„ฑ๋ฆฝํ•˜๊ธฐ ์œ„ํ•ด ๋ฐ˜๋“œ์‹œ ์ฐธ์ด์–ด์•ผ ํ•˜๋Š” ์ „์ œ ์กฐ๊ฑด** +- Redis ์žฅ์•  ์‹œ fallback์ด ๊ตฌํ˜„๋˜์–ด์•ผ ํ•จ. ์บ์‹œ๋Š” ์„ฑ๋Šฅ ์ตœ์ ํ™” ์ˆ˜๋‹จ์ด๋ฏ€๋กœ Redis ์—†์ด๋„ ์„œ๋น„์Šค๊ฐ€ ์ •์ƒ ๋™์ž‘ํ•ด์•ผ ํ•˜๋ฉฐ, ์ด๋ฅผ ์œ„ํ•ด ๋ชฉํ‘œ โ‘ ์˜ ์ธ๋ฑ์Šค ์ตœ์ ํ™”๊ฐ€ ๋ฐ˜๋“œ์‹œ ์„ ํ–‰๋˜์–ด์•ผ ํ•จ +- ์บ์‹œ์— ์ €์žฅํ•˜๋Š” DTO๊ฐ€ JSON ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”๊ฐ€ ๊ฐ€๋Šฅํ•ด์•ผ ํ•จ. ํ˜„์žฌ Java `record`๋Š” Jackson์œผ๋กœ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅํ•˜๋‚˜, `BigDecimal`, `ZonedDateTime` ๋“ฑ์˜ ์ง๋ ฌํ™” ์ •๋ฐ€๋„ ๋ณด์žฅ ํ•„์š” +- ๋ฌธ์ž์—ด RedisTemplate + ObjectMapper ์ง๋ ฌํ™” ์กฐํ•ฉ์ด ์•ˆ์ •์ ์œผ๋กœ ๋™์ž‘ํ•ด์•ผ ํ•จ + +**2. ํŠธ๋ž˜ํ”ฝ 10๋ฐฐ ์ฆ๊ฐ€ ์‹œ ๊ฐ€์žฅ ๋จผ์ € ๋ณ‘๋ชฉ์ด ๋  ์ง€์ ** +- **๋ชฉ๋ก ์บ์‹œ์˜ ํ‚ค ํญ๋ฐœ (key explosion)**. `brandId ร— sortType ร— page ร— size` ์กฐํ•ฉ์ด ์ˆ˜์ฒœ~์ˆ˜๋งŒ ๊ฐœ๊ฐ€ ๋  ์ˆ˜ ์žˆ์Œ. ์˜ˆ: ๋ธŒ๋žœ๋“œ 100๊ฐœ ร— ์ •๋ ฌ 3์ข… ร— ํŽ˜์ด์ง€ 500 = 15๋งŒ ํ‚ค. Redis ๋ฉ”๋ชจ๋ฆฌ์™€ eviction ์ •์ฑ…์ด ๋ฌธ์ œ๊ฐ€ ๋จ +- **cold-cache worst-case**: `brandId` ์—†๋Š” `PRICE_ASC`๋Š” 1000๋งŒ๊ฑด์—์„œ 3.88์ดˆ๊นŒ์ง€ ์ƒ์Šนํ•˜๋ฏ€๋กœ, steady-state hit rate ๊ด€๋ฆฌ๊ฐ€ ๋” ์ค‘์š”ํ•ด์ง + +**3. ์บ์‹œ ์ ์ค‘๋ฅ  30% ์ดํ•˜ ์‹œ ๋ฌธ์ œ** +- **thundering herd (cache stampede)**: ์ธ๊ธฐ ํ‚ค์˜ TTL ๋งŒ๋ฃŒ ์‹œ ์ˆ˜๋ฐฑ ์š”์ฒญ์ด ๋™์‹œ์— DB๋กœ ๋ชฐ๋ฆผ. ๋‹ค๋งŒ ์ธ๋ฑ์Šค ์ตœ์ ํ™”(๋ชฉํ‘œ โ‘ )๊ฐ€ ์™„๋ฃŒ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ, Full Table Scan์ด ์•„๋‹Œ ์ธ๋ฑ์Šค ์Šค์บ”์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์–ด DB๊ฐ€ ๋ฒ„ํ‹ธ ์ˆ˜ ์žˆ์Œ. ์ธ๋ฑ์Šค๊ฐ€ ์—†๋‹ค๋ฉด ์ฆ‰์‹œ connection pool ๊ณ ๊ฐˆ +- **๋ชฉ๋ก ์บ์‹œ๋Š” ํƒœ์ƒ์ ์œผ๋กœ ์ ์ค‘๋ฅ ์ด ๋‚ฎ์„ ์ˆ˜ ์žˆ์Œ**: page ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋”ฐ๋ผ ํ‚ค๊ฐ€ ๋ถ„์‚ฐ๋˜๊ณ , ์ข‹์•„์š” ๋ณ€๊ฒฝ๋งˆ๋‹ค ๋ฌดํšจํ™”๋˜๋ฉด hit rate 30%๋„ ์–ด๋ ค์šธ ์ˆ˜ ์žˆ์Œ. ์ƒ์„ธ ์บ์‹œ๋Š” ์ƒ๋Œ€์ ์œผ๋กœ hit rate๊ฐ€ ๋†’์„ ๊ฒƒ + +**4. ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์ด ๊นจ์งˆ ์ˆ˜ ์žˆ๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค** +- **(a) ์บ์‹œ ๋ฌดํšจํ™” ์‹คํŒจ**: ์ƒํ’ˆ ์ˆ˜์ • TX๋Š” ์ปค๋ฐ‹๋˜์—ˆ์œผ๋‚˜ Redis eviction ๋ช…๋ น์ด ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋กœ ์‹คํŒจ. ์ดํ›„ ์‚ฌ์šฉ์ž๊ฐ€ stale ๋ฐ์ดํ„ฐ๋ฅผ TTL ๋งŒ๋ฃŒ๊นŒ์ง€ ๊ณ„์† ๋ณด๊ฒŒ ๋จ +- **(b) ์ข‹์•„์š” โ†’ ์บ์‹œ ๋ฌดํšจํ™” ์ˆœ์„œ ์—ญ์ „**: ์ข‹์•„์š” TX ์ปค๋ฐ‹๊ณผ ์บ์‹œ ๋ฌดํšจํ™” ์‚ฌ์ด์— ๋‹ค๋ฅธ ์š”์ฒญ์ด DB์—์„œ ์กฐํšŒํ•˜์—ฌ ์บ์‹œ๋ฅผ ๊ฐฑ์‹ ํ•˜๋ฉด, ์งํ›„ ๋ฌดํšจํ™”๊ฐ€ ์‹คํ–‰๋˜์–ด **์˜คํžˆ๋ ค ์ตœ์‹  ์บ์‹œ๊ฐ€ ์‚ญ์ œ๋จ** (ABA problem) +- **(c) ์ƒ์„ธ ์บ์‹œ์— ์ข‹์•„์š” ์ˆ˜ ํฌํ•จ**: `ProductDetailOutDto`์— `likeCount`๊ฐ€ ์žˆ์œผ๋ฏ€๋กœ ์ข‹์•„์š” ๋ณ€๊ฒฝ๋งˆ๋‹ค ์ƒ์„ธ ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”ํ•ด์•ผ ํ•จ. ๋ˆ„๋ฝ ์‹œ ์ƒ์„ธ ํŽ˜์ด์ง€์˜ ์ข‹์•„์š” ์ˆ˜๊ฐ€ stale + +**5. ๊ฐ€์žฅ ๋‚˜์ค‘๊นŒ์ง€ ๋ฏธ๋ฃฐ ์ˆ˜ ์žˆ๋Š” ๊ฐœ์„ ** +- **๋ชฉ๋ก ์บ์‹œ**. ํ‚ค ์กฐํ•ฉ ํญ๋ฐœ, ๋นˆ๋ฒˆํ•œ ๋ฌดํšจํ™”, ๋‚ฎ์€ hit rate ๋“ฑ cost-benefit์ด ๋ถˆ๋ฆฌ. ์ธ๋ฑ์Šค ์ตœ์ ํ™”(๋ชฉํ‘œ โ‘ )๋งŒ์œผ๋กœ 10๋งŒ๊ฑด ์ˆ˜์ค€์—์„œ๋Š” DB ๋ถ€ํ•˜๊ฐ€ ์ˆ˜์šฉ ๊ฐ€๋Šฅํ•  ์ˆ˜ ์žˆ์Œ. ์ƒ์„ธ ์บ์‹œ๋งŒ ๋จผ์ € ์ ์šฉํ•ด๋„ ํšจ๊ณผ์  + +**6. ๊ฐ€์žฅ ๋จผ์ € ์†๋Œ€์•ผ ํ•  ์œ„ํ—˜ ์š”์†Œ** +- **Redis ์žฅ์•  ์‹œ fallback ์ „๋žต ๊ตฌํ˜„**. ์บ์‹œ๋ฅผ ๋„์ž…ํ•˜๋Š” ์ˆœ๊ฐ„ Redis๊ฐ€ ์ƒˆ๋กœ์šด ์˜์กด์„ฑ์ด ๋จ. fallback ์—†์ด ๋ฐฐํฌํ•˜๋ฉด Redis ๋‹ค์šด = ์„œ๋น„์Šค ๋‹ค์šด. `try-catch` ๊ธฐ๋ฐ˜ graceful degradation์„ ์บ์‹œ ์ ์šฉ๊ณผ ๋™์‹œ์— ๊ตฌํ˜„ํ•ด์•ผ ํ•จ + +--- + +## ์ข…ํ•ฉ ๊ต์ฐจ ๋ถ„์„ + +### ์„ธ ๋ชฉํ‘œ ๊ฐ„ ์˜์กด ๊ด€๊ณ„ + +``` +โ‘  ์ธ๋ฑ์Šค ์ตœ์ ํ™” โ†โ”€โ”€ ๋…๋ฆฝ (๊ฐ€์žฅ ๋จผ์ € ์ˆ˜ํ–‰ ๊ฐ€๋Šฅ) + โ†‘ +โ‘ก ์ข‹์•„์š” ๋น„์ •๊ทœํ™” โ”€โ”€ ์ด๋ฏธ ์™„๋ฃŒ. ์ธ๋ฑ์Šค๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ๋จ (โ‘ ๊ณผ ๋ณ‘ํ•ฉ) + โ†‘ +โ‘ข ์บ์‹œ ์ ์šฉ โ†โ”€โ”€ โ‘ โ‘ก๊ฐ€ ์„ ํ–‰๋˜์–ด์•ผ ์บ์‹œ miss ์‹œ ์•ˆ์ „ +``` + +### ์šฐ์„ ์ˆœ์œ„ ์ œ์•ˆ + +| ์ˆœ์„œ | ์ž‘์—… | ์ด์œ  | +|------|------|------| +| 1 | โ‘  + โ‘ก ๋ณ‘ํ•ฉ: ์ธ๋ฑ์Šค ์ถ”๊ฐ€ + EXPLAIN ๋ถ„์„ | ๋น„์ •๊ทœํ™”๋Š” ์ด๋ฏธ ๋ผ์žˆ์œผ๋ฏ€๋กœ ์ธ๋ฑ์Šค๋งŒ ์ถ”๊ฐ€. ์บ์‹œ miss์˜ ์•ˆ์ „๋ง ์—ญํ•  | +| 2 | โ‘ข ์ƒ์„ธ ์บ์‹œ | ํ‚ค ์„ค๊ณ„๊ฐ€ ๋‹จ์ˆœํ•˜๊ณ  hit rate ๋†’์Œ | +| 3 | โ‘ข ๋ชฉ๋ก ์บ์‹œ | ํ‚ค ํญ๋ฐœยท๋ฌดํšจํ™” ๋ณต์žก๋„๊ฐ€ ๋†’์•„ ๊ฐ€์žฅ ๋ฆฌ์Šคํฌ ํผ | + +### ์ฒดํฌ๋ฆฌ์ŠคํŠธ ๋Œ€์‘ ํ˜„ํ™ฉ + +| ์ฒดํฌ๋ฆฌ์ŠคํŠธ ํ•ญ๋ชฉ | ์„ค๊ณ„ ๋Œ€์‘ | +|---|---| +| **[Index]** brandId ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰ + ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ ์ฒ˜๋ฆฌ | 6๊ฐ€์ง€ ์œ ์ฆˆ์ผ€์ด์Šค ๋งคํŠธ๋ฆญ์Šค ๋ถ„์„ ์™„๋ฃŒ. 3์ข… ๋ณตํ•ฉ ์ธ๋ฑ์Šค ํ›„๋ณด ๋„์ถœ | +| **[Index]** ์กฐํšŒ ํ•„ํ„ฐยท์ •๋ ฌ ์กฐ๊ฑด๋ณ„ ์œ ์ฆˆ์ผ€์ด์Šค ๋ถ„์„ + ์ „ํ›„ ์„ฑ๋Šฅ๋น„๊ต | EXPLAIN ๋ถ„์„ ๊ณ„ํš ์ˆ˜๋ฆฝ (AS-IS/TO-BE 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค ๋น„๊ต) | +| **[Structure]** ์ข‹์•„์š” ์ˆ˜ ์กฐํšŒ ๋ฐ ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ ๊ฐ€๋Šฅ ๊ตฌ์กฐ | ์ด๋ฏธ ๊ตฌํ˜„๋จ. `products.like_count` ๋น„์ •๊ทœํ™” + `LIKES_DESC` ์ •๋ ฌ. ์ธ๋ฑ์Šค ์ถ”๊ฐ€๋กœ ์„ฑ๋Šฅ ๊ฐœ์„  | +| **[Structure]** ์ข‹์•„์š” ์ ์šฉ/ํ•ด์ œ ์‹œ ๋™๊ธฐํ™” | ์ด๋ฏธ ๊ตฌํ˜„๋จ. ๋™์ผ TX ๋‚ด ์›์ž์  UPDATE (`like_count + 1` / `like_count - 1`) | +| **[Cache]** Redis ์บ์‹œ + TTL/๋ฌดํšจํ™” ์ „๋žต | RedisTemplate ์ง์ ‘ ์‚ฌ์šฉ. ์ƒ์„ธ(Facade, TTL 2๋ถ„) + ID ๋ฆฌ์ŠคํŠธ(Service, TTL 3๋ถ„). targeted write-through + safety-net TTL | +| **[Cache]** ์บ์‹œ ๋ฏธ์Šค ์‹œ ์ •์ƒ ๋™์ž‘ | Cache-Aside + Redis ์žฅ์•  ์‹œ try-catch fallback to DB. TTL jitter + PER + LocalCacheLock ์ ์šฉ | + +### ๊ฐ€์žฅ ํฐ ๊ตฌ์กฐ์  ๋ฆฌ์Šคํฌ Top 3 + +1. **์บ์‹œ ๋ฌดํšจํ™”์™€ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ์˜ ์›์ž์„ฑ ๋ฏธ๋ณด์žฅ**: ํ˜„์žฌ ์•„ํ‚คํ…์ฒ˜์—์„œ `@Transactional` ์ปค๋ฐ‹ ํ›„ Redis ๋ฌดํšจํ™”๋ฅผ ์ˆ˜ํ–‰ํ•˜๋ฉด, ๊ทธ ์‚ฌ์ด์— ์ •ํ•ฉ์„ฑ gap์ด ๋ฐœ์ƒ. `@TransactionalEventListener(phase = AFTER_COMMIT)` ํŒจํ„ด์„ ์จ๋„ Redis ํ˜ธ์ถœ ์‹คํŒจ ์‹œ ๋ณต๊ตฌ ์ˆ˜๋‹จ์ด ์—†์Œ + +2. **๋ชฉ๋ก ์บ์‹œ์˜ ํ‚ค ํญ๋ฐœ + ์ข‹์•„์š” ๋ณ€๊ฒฝ ์‹œ ๋Œ€๋Ÿ‰ ๋ฌดํšจํ™”**: ์ข‹์•„์š”๊ฐ€ ๋นˆ๋ฒˆํ•œ ์„œ๋น„์Šค์—์„œ `LIKES_DESC` ์ •๋ ฌ์˜ ๋ชจ๋“  ํŽ˜์ด์ง€ ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”ํ•˜๋Š” ๊ฒƒ์€ ์‚ฌ์‹ค์ƒ "์บ์‹œ๋ฅผ ์“ฐ์ง€ ์•Š๋Š” ๊ฒƒ"๊ณผ ๊ฐ™์•„์งˆ ์ˆ˜ ์žˆ์Œ + +3. **์ธ๋ฑ์Šค 3๋ฒŒ ์œ ์ง€ ๋น„์šฉ vs. ์ฟผ๋ฆฌ ์„ฑ๋Šฅ trade-off**: ์ •๋ ฌ ํƒ€์ž… 3์ข…(LATEST, PRICE_ASC, LIKES_DESC)์— ๋Œ€ํ•ด ๊ฐ๊ฐ ์ตœ์  ์ธ๋ฑ์Šค๋ฅผ ๋งŒ๋“ค๋ฉด INSERT/UPDATE ์‹œ ์ธ๋ฑ์Šค ์œ ์ง€ ๋น„์šฉ ์ฆ๊ฐ€. `like_count` UPDATE๊ฐ€ ๋นˆ๋ฒˆํ•˜๋ฉด `(brand_id, deleted_at, like_count)` ์ธ๋ฑ์Šค์˜ ์žฌ์ •๋ ฌ ๋น„์šฉ์ด ์“ฐ๊ธฐ ์„ฑ๋Šฅ์„ ์•…ํ™”์‹œํ‚ฌ ์ˆ˜ ์žˆ์Œ + +--- + +## ์ฐธ๊ณ ์ž๋ฃŒ + +### ์บ์‹œ TTL ์„ค์ • ๊ทผ๊ฑฐ + +| ์ถœ์ฒ˜ | ๋งํฌ | +|------|------| +| AWS - Database Caching Strategies Using Redis (Cache Validity) | https://docs.aws.amazon.com/whitepapers/latest/database-caching-strategies-using-redis/cache-validity.html | +| AWS - ElastiCache Caching Strategies | https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/Strategies.html | +| Redis ๊ณต์‹ ๋ธ”๋กœ๊ทธ - Cache Optimization Strategies | https://redis.io/blog/guide-to-cache-optimization-strategies/ | +| ByteByteGo - A Crash Course in Caching (Final Part) | https://blog.bytebytego.com/p/a-crash-course-in-caching-final-part | +| ByteByteGo - A Guide to Top Caching Strategies | https://blog.bytebytego.com/p/a-guide-to-top-caching-strategies | +| ์˜ฌ๋ฆฌ๋ธŒ์˜ ํ…Œํฌ๋ธ”๋กœ๊ทธ - ๊ณ ์„ฑ๋Šฅ ์บ์‹œ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ | https://oliveyoung.tech/2024-12-10/present-promotion-multi-layer-cache/ | +| ์นด์นด์˜คํŽ˜์ด ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ - ๋ถ„์‚ฐ ์‹œ์Šคํ…œ์—์„œ ๋กœ์ปฌ ์บ์‹œ ํ™œ์šฉํ•˜๊ธฐ | https://tech.kakaopay.com/post/local-caching-in-distributed-systems/ | + +### ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต ๊ทผ๊ฑฐ + +| ์ถœ์ฒ˜ | ๋งํฌ | +|------|------| +| Redis ๊ณต์‹ - Cache Invalidation | https://redis.io/glossary/cache-invalidation/ | +| Redis ๊ณต์‹ ๋ธ”๋กœ๊ทธ - Three Ways to Maintain Cache Consistency | https://redis.io/blog/three-ways-to-maintain-cache-consistency/ | +| AWS Builders Library - Caching Challenges and Strategies | https://aws.amazon.com/builders-library/caching-challenges-and-strategies/ | +| Microsoft Azure - Cache-Aside Pattern | https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside | +| 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 | +| daily.dev - Cache Invalidation vs. Expiration Best Practices | https://daily.dev/blog/cache-invalidation-vs-expiration-best-practices | +| Toss Tech - Cache Traffic Tips | https://toss.tech/article/cache-traffic-tip | + +### ์ธ๋ฑ์Šค ๋ฐ ์ฟผ๋ฆฌ ์ตœ์ ํ™” + +| ์ถœ์ฒ˜ | ๋งํฌ | +|------|------| +| ์ฟผ๋ฆฌ ํŠœ๋‹๊ณผ ์ธ๋ฑ์Šค ์ตœ์ ํ™” (WikiDocs) | https://wikidocs.net/226253 | +| ์นด์นด์˜ค ํ…Œํฌ - MySQL ๋ฐฉํ–ฅ๋ณ„ ์ธ๋ฑ์Šค | https://tech.kakao.com/posts/351 | + +### Spring + Redis ๊ตฌํ˜„ + +| ์ถœ์ฒ˜ | ๋งํฌ | +|------|------| +| Spring ๊ณต์‹ - Caching | https://docs.spring.io/spring-boot/reference/io/caching.html | +| Spring Data Redis - Redis Cache Reference | https://docs.spring.io/spring-data/redis/reference/redis/redis-cache.html | +| Baeldung - Spring Data Redis Tutorial | https://www.baeldung.com/spring-data-redis-tutorial | diff --git a/round5-docs/02-performance-improvement-plan.md b/round5-docs/02-performance-improvement-plan.md new file mode 100644 index 000000000..f80a3b8a3 --- /dev/null +++ b/round5-docs/02-performance-improvement-plan.md @@ -0,0 +1,1020 @@ +# ์„ฑ๋Šฅ ๊ฐœ์„  ๊ตฌํ˜„ ๊ณ„ํš + +## Context + +์ƒํ’ˆ ๋ชฉ๋ก/์ƒ์„ธ ์กฐํšŒ API์˜ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•œ๋‹ค. ํ˜„์žฌ ์ƒํƒœ: +- `products` ํ…Œ์ด๋ธ”์— PK ์™ธ ์ธ๋ฑ์Šค ์—†์Œ โ†’ 10๋งŒ๊ฑด ์ด์ƒ์—์„œ Full Table Scan + filesort +- ์ƒํ’ˆ ๋ชฉ๋ก ์ฟผ๋ฆฌ๊ฐ€ `products LEFT JOIN brands`๋กœ ๋งค๋ฒˆ JOIN ์ˆ˜ํ–‰ +- Redis ์บ์‹œ ๋ฏธ์ ์šฉ +- ์ข‹์•„์š” ์ˆ˜ ๋น„์ •๊ทœํ™”(`like_count`)์™€ ๋™๊ธฐํ™”๋Š” ์ด๋ฏธ ๊ตฌํ˜„ ์™„๋ฃŒ + +**3๊ฐ€์ง€ ๊ฐœ์„  ์ถ•**: +1. **Read Model** โ€” ์กฐํšŒ ์ „์šฉ ํ…Œ์ด๋ธ”(`product_read_model`)๋กœ JOIN ์ œ๊ฑฐ +2. **์ธ๋ฑ์Šค** โ€” Read Model ํ…Œ์ด๋ธ”์— ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ +3. **์บ์‹œ** โ€” ์ƒ์„ธ/๋ชฉ๋ก API์— Redis Cache-Aside ์ ์šฉ + +**์š”๊ตฌ์‚ฌํ•ญ ์ฒดํฌ๋ฆฌ์ŠคํŠธ**: + +| # | ํ•ญ๋ชฉ | ๋Œ€์‘ | +|---|------|------| +| 1 | [Index] brandId ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰ + ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ | Task 1: Read Model ์ธ๋ฑ์Šค | +| 2 | [Index] ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„ ์ธ๋ฑ์Šค + ์ „ํ›„ ์„ฑ๋Šฅ๋น„๊ต | Task 1: EXPLAIN AS-IS vs TO-BE | +| 3 | [Structure] ์ข‹์•„์š” ์ˆ˜ ์กฐํšŒ + ์ •๋ ฌ | **์ด๋ฏธ ์™„๋ฃŒ** (`products.like_count` + `LIKES_DESC` ์ •๋ ฌ) | +| 4 | [Structure] ์ข‹์•„์š” ์ ์šฉ/ํ•ด์ œ ์‹œ ๋™๊ธฐํ™” | **์ด๋ฏธ ์™„๋ฃŒ** (๋™์ผ TX ๋‚ด ์›์ž์  UPDATE) | +| 5 | [Cache] Redis ์บ์‹œ + TTL/๋ฌดํšจํ™” ์ „๋žต | Task 2~3: ์ƒ์„ธ + ๋ชฉ๋ก ์บ์‹œ | +| 6 | [Cache] ์บ์‹œ ๋ฏธ์Šค ์‹œ ์ •์ƒ ๋™์ž‘ | Task 2: try-catch ์žฅ์•  ๊ฒฉ๋ฆฌ | + +--- + +## ๋น„์ •๊ทœํ™” vs Read Model (Materialized View) ๋น„๊ต + +| ๊ด€์  | ๋น„์ •๊ทœํ™” (`like_count`) | Read Model (`product_read_model`) | +|------|----------------------|----------------------------------| +| ๊ตฌ์กฐ | ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ์ปฌ๋Ÿผ ์ถ”๊ฐ€ | ์กฐํšŒ ์ „์šฉ ๋ณ„๋„ ํ…Œ์ด๋ธ” | +| ์ •ํ•ฉ์„ฑ | ๋™์ผ TX ๋‚ด ์›์ž์  UPDATE โ†’ ์ฆ‰์‹œ ์ผ๊ด€์„ฑ | ๋™์ผ TX ๋‚ด ๋™๊ธฐ sync โ†’ ์ฆ‰์‹œ ์ผ๊ด€์„ฑ | +| ์กฐํšŒ ์„ฑ๋Šฅ | COUNT ์„œ๋ธŒ์ฟผ๋ฆฌ/JOIN ์ œ๊ฑฐ โ†’ ๋‹จ์ผ ์ปฌ๋Ÿผ ์ •๋ ฌ | JOIN ์ž์ฒด๋ฅผ ์ œ๊ฑฐ โ†’ ๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT | +| ์ธ๋ฑ์Šค | write ํ…Œ์ด๋ธ”์— ์ธ๋ฑ์Šค ์ถ”๊ฐ€ โ†’ ์“ฐ๊ธฐ ๋ถ€ํ•˜ ์ฆ๊ฐ€ | read ์ „์šฉ ํ…Œ์ด๋ธ”์— ์ธ๋ฑ์Šค โ†’ write ๋ฌด์˜ํ–ฅ | +| ํ™•์žฅ์„ฑ | ๋น„์ •๊ทœํ™” ๋Œ€์ƒ๋งˆ๋‹ค ์ปฌ๋Ÿผ ์ถ”๊ฐ€ ํ•„์š” | ์กฐํšŒ์— ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ์ž์œ ๋กญ๊ฒŒ ํฌํ•จ ๊ฐ€๋Šฅ | +| ๋ณต์žก๋„ | ๋‚ฎ์Œ (์ปฌ๋Ÿผ 1๊ฐœ + UPDATE SQL) | ์ค‘๊ฐ„ (ํ…Œ์ด๋ธ” + ๋™๊ธฐํ™” ๋กœ์ง) | + +**์ด ํ”„๋กœ์ ํŠธ์˜ ์„ ํƒ**: +- **๋น„์ •๊ทœํ™”**: ์ข‹์•„์š” ์ˆ˜(`like_count`) โ€” ๋‹จ์ผ ์ง‘๊ณ„ ๊ฐ’, ๋™๊ธฐํ™”๊ฐ€ ๋‹จ์ˆœํ•˜๋ฏ€๋กœ ๊ธฐ์กด ํ…Œ์ด๋ธ”์— ์ปฌ๋Ÿผ ์ถ”๊ฐ€๋กœ ํ•ด๊ฒฐ +- **Read Model**: ๋ธŒ๋žœ๋“œ๋ช…(`brand_name`) โ€” ์ƒํ’ˆ ๋ชฉ๋ก/์ƒ์„ธ์—์„œ ๋งค๋ฒˆ `LEFT JOIN brands` ์ˆ˜ํ–‰. Read Model๋กœ JOIN ์ œ๊ฑฐ + ํ–ฅํ›„ ์ถ”๊ฐ€ ์ •๋ณด(์นดํ…Œ๊ณ ๋ฆฌ๋ช… ๋“ฑ) ํ™•์žฅ ์šฉ์ด + +--- + +## ๊ฒฐ์ •์‚ฌํ•ญ + +| ๊ฒฐ์ • | ์„ ํƒ | ๊ทผ๊ฑฐ | +|------|------|------| +| Read Model ๋ฐฉ์‹ | **๋ณ„๋„ `product_read_model` ํ…Œ์ด๋ธ”** | JOIN ์ œ๊ฑฐ + write/read ๋ถ„๋ฆฌ + ํ™•์žฅ์„ฑ | +| Read Model ๋™๊ธฐํ™” | **๋™์ผ TX ๋‚ด ๋™๊ธฐ sync** | ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ๋ฏธ๋„์ž… ์ƒํƒœ. ์‹ค์‹œ๊ฐ„ ์ •ํ•ฉ์„ฑ ๋ณด์žฅ | +| ์ธ๋ฑ์Šค ์œ„์น˜ | **Read Model ํ…Œ์ด๋ธ”์—๋งŒ ์ ์šฉ** | write ํ…Œ์ด๋ธ” ์“ฐ๊ธฐ ์„ฑ๋Šฅ ์œ ์ง€. ์‚ฌ์šฉ์ž ์กฐํšŒ๋Š” read model ๊ฒฝ์œ  | +| ์บ์‹œ ์ง๋ ฌํ™” | **StringRedisTemplate + ObjectMapper** | ๊ธฐ์กด modules/redis ์ˆ˜์ • ๋ถˆํ•„์š”, ์ˆœ์ˆ˜ JSON, ๋””๋ฒ„๊น… ์šฉ์ด | +| ์ƒ์„ธ ์บ์‹œ | **์ ์šฉ** (TTL 10๋ถ„) | ์š”๊ตฌ์‚ฌํ•ญ์— '์ƒํ’ˆ ์ƒ์„ธ API์— ์บ์‹œ ์ ์šฉ' ๋ช…์‹œ | +| ๋ชฉ๋ก ์บ์‹œ | **์ ์šฉ** (TTL 5๋ถ„) | ์š”๊ตฌ์‚ฌํ•ญ์— '์ƒํ’ˆ ๋ชฉ๋ก API์— ์บ์‹œ ์ ์šฉ' ๋ช…์‹œ | +| ๋ชฉ๋ก ์บ์‹œ ๋ฌดํšจํ™” | **SCAN + TTL ๋ณ‘ํ–‰** | Active invalidation + safety-net TTL | +| ๊ด€๋ฆฌ์ž API ์บ์‹œ | **๋ฏธ์ ์šฉ** | ๊ด€๋ฆฌ์ž๋Š” ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ํ•„์š” | + +--- + +## ์•„ํ‚คํ…์ฒ˜ ์ œ์•ฝ์‚ฌํ•ญ (ArchUnit) + +`LayerDependencyArchTest`์˜ ๊ด€๋ จ ๊ทœ์น™: + +| ๊ทœ์น™ | ์˜ํ–ฅ | +|------|------| +| Facade โ†’ Infrastructure ๊ธˆ์ง€ | Facade์—์„œ CacheManager ์ง์ ‘ ์‚ฌ์šฉ ๋ถˆ๊ฐ€ | +| Facade โ†’ Port **์ธํ„ฐํŽ˜์ด์Šค** ๊ธˆ์ง€ | Facade์—์„œ CachePort ์ธํ„ฐํŽ˜์ด์Šค๋„ ์‚ฌ์šฉ ๋ถˆ๊ฐ€ | +| Service โ†’ Service ๊ธˆ์ง€ | Service ๊ฐ„ ์ง์ ‘ ํ˜ธ์ถœ ๋ถˆ๊ฐ€ | +| **Service โ†’ Infrastructure** | **๊ธˆ์ง€ ๊ทœ์น™ ์—†์Œ** | + +**ํ•ต์‹ฌ**: Service โ†’ Infrastructure๋ฅผ ์ฐจ๋‹จํ•˜๋Š” ArchUnit ๊ทœ์น™์ด ์—†๋‹ค. ๋”ฐ๋ผ์„œ Port ์ธํ„ฐํŽ˜์ด์Šค ์—†์ด Service์—์„œ `ProductCacheManager`(infrastructure)๋ฅผ ์ง์ ‘ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋‹ค. + +**์บ์‹œ ๋ฐฐ์น˜ ์ „๋žต**: +- **๋ชฉ๋ก ์บ์‹œ**: `ProductQueryService`์—์„œ Cache-Aside ์ฒ˜๋ฆฌ (Service ๋‚ด๋ถ€, Facade ๋ฌด๊ด€) +- **์ƒ์„ธ ์บ์‹œ**: `ProductQueryFacade`๊ฐ€ Service์˜ ์บ์‹œ read/write ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ (์ƒ์„ธ DTO = Product + BrandName 2๊ฐœ ๋„๋ฉ”์ธ ์กฐํ•ฉ์ด๋ฏ€๋กœ Facade ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ) +- **์บ์‹œ ๋ฌดํšจํ™”**: `ProductCommandService`์—์„œ mutation ํ›„ eviction ์ฒ˜๋ฆฌ + +--- + +## Task ๋ถ„ํ•ด ๋ฐ ์‹คํ–‰ ์ˆœ์„œ + +``` +Phase 1 (๋ณ‘๋ ฌ): [Task 1: Read Model + ์ธ๋ฑ์Šค] || [Task 2: ์บ์‹œ ์ธํ”„๋ผ] + โ†“ ํŒŒ์ผ ์ถฉ๋Œ ์—†์Œ +Phase 2: [Task 3: ์บ์‹œ ์ ์šฉ + Read Model ์ฟผ๋ฆฌ ์ „ํ™˜] + โ†“ +Phase 3: [QA: ์ „์ฒด ํ…Œ์ŠคํŠธ ๊ฒ€์ฆ (ArchUnit ํฌํ•จ)] +``` + +**๋ณ‘๋ ฌ ์‹คํ–‰ ๊ทผ๊ฑฐ (Phase 1)**: +- Task 1: `ProductReadModelEntity` + `ProductReadModelRepository` + DDL + EXPLAIN ํ…Œ์ŠคํŠธ +- Task 2: `ProductCacheManager` + ํ…Œ์ŠคํŠธ +- ํŒŒ์ผ ๊ฒน์นจ ์—†์Œ + +--- + +## Task 1: Read Model + ์ธ๋ฑ์Šค + +**๋ชฉํ‘œ**: ์กฐํšŒ ์ „์šฉ `product_read_model` ํ…Œ์ด๋ธ” ์ƒ์„ฑ + ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ + ๋™๊ธฐํ™” + ์ฟผ๋ฆฌ ์ „ํ™˜ + EXPLAIN ์ „ํ›„ ๋น„๊ต +**์ฒดํฌ๋ฆฌ์ŠคํŠธ ๋Œ€์‘**: [Index] 1, 2๋ฒˆ + [Structure] Read Model + +### 1-1. Read Model ํ…Œ์ด๋ธ” ์„ค๊ณ„ + +```sql +CREATE TABLE product_read_model ( + id BIGINT PRIMARY KEY, -- products.id์™€ ๋™์ผ (FK ์•„๋‹˜, ๋™๊ธฐํ™”๋กœ ๊ด€๋ฆฌ) + brand_id BIGINT NOT NULL, + brand_name VARCHAR(100), -- brands.name ๋น„์ •๊ทœํ™” + name VARCHAR(200) NOT NULL, -- ์ƒํ’ˆ๋ช… + price DECIMAL(12,2) NOT NULL, + stock BIGINT NOT NULL, + description VARCHAR(1000), -- nullable + like_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, -- soft delete + + -- ๋ณตํ•ฉ ์ธ๋ฑ์Šค: ์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘ + deleted_at ํ•„ํ„ฐ + ์ •๋ ฌ + INDEX idx_read_brand_deleted_created (brand_id, deleted_at, created_at), + INDEX idx_read_brand_deleted_price (brand_id, deleted_at, price), + INDEX idx_read_brand_deleted_likecount (brand_id, deleted_at, like_count) +); +``` + +> **์ธ๋ฑ์Šค๋ฅผ Read Model์—๋งŒ ์ ์šฉํ•˜๋Š” ์ด์œ **: `products` ์›๋ณธ ํ…Œ์ด๋ธ”์€ ์“ฐ๊ธฐ ์ „์šฉ. ์ธ๋ฑ์Šค๋ฅผ ์›๋ณธ์— ์ถ”๊ฐ€ํ•˜๋ฉด INSERT/UPDATE ์‹œ ์ธ๋ฑ์Šค ์œ ์ง€ ๋น„์šฉ ๋ฐœ์ƒ. Read Model์€ ์กฐํšŒ ์ „์šฉ์ด๋ฏ€๋กœ ์ธ๋ฑ์Šค๋ฅผ ์ž์œ ๋กญ๊ฒŒ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ. + +### 1-2. ์‹ ๊ทœ ํŒŒ์ผ โ€” Entity + +**`apps/.../catalog/product/infrastructure/entity/ProductReadModelEntity.java`** + +```java +@Entity +@Table(name = "product_read_model", indexes = { + @Index(name = "idx_read_brand_deleted_created", columnList = "brand_id, deleted_at, created_at"), + @Index(name = "idx_read_brand_deleted_price", columnList = "brand_id, deleted_at, price"), + @Index(name = "idx_read_brand_deleted_likecount", columnList = "brand_id, deleted_at, like_count") +}) +public class ProductReadModelEntity { + @Id private Long id; // products.id (AUTO_INCREMENT ์•„๋‹˜, ์ง์ ‘ ์„ค์ •) + private Long brandId; + private String brandName; // ๋น„์ •๊ทœํ™” + private String name; + @Column(precision = 12, scale = 2) + private BigDecimal price; + private Long stock; + @Column(length = 1000) + private String description; + private Long likeCount; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + private ZonedDateTime deletedAt; + + // of(Product, brandName): ์ •์  ํŒฉํ† ๋ฆฌ +} +``` + +### 1-3. ์‹ ๊ทœ ํŒŒ์ผ โ€” JPA Repository + +**`apps/.../catalog/product/infrastructure/jpa/ProductReadModelJpaRepository.java`** + +```java +public interface ProductReadModelJpaRepository extends JpaRepository { + @Modifying @Query("UPDATE ProductReadModelEntity e SET e.brandName = :brandName WHERE e.brandId = :brandId") + void updateBrandNameByBrandId(@Param("brandId") Long brandId, @Param("brandName") String brandName); + + @Modifying @Query("UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount + 1 WHERE e.id = :id") + void increaseLikeCount(@Param("id") Long id); + + @Modifying @Query("UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount - 1 WHERE e.id = :id AND e.likeCount > 0") + void decreaseLikeCount(@Param("id") Long id); +} +``` + +### 1-4. ์‹ ๊ทœ ํŒŒ์ผ โ€” Domain Repository Interface + Implementation + +**`apps/.../catalog/product/domain/repository/ProductReadModelRepository.java`** + +```java +/** + * ์ƒํ’ˆ Read Model ๋™๊ธฐํ™” ๋ฆฌํฌ์ง€ํ† ๋ฆฌ + * - write ๊ฒฝ๋กœ์—์„œ product_read_model ํ…Œ์ด๋ธ”์„ ๋™๊ธฐํ™” + * - ๊ตฌํ˜„์ฒด: ProductReadModelRepositoryImpl (infrastructure/repository/) + */ +public interface ProductReadModelRepository { + void save(Product product, String brandName); + void delete(Long productId); + void increaseLikeCount(Long productId); + void decreaseLikeCount(Long productId); + void updateStock(Long productId, Long newStock); + void updateBrandName(Long brandId, String newBrandName); +} +``` + +**`apps/.../catalog/product/infrastructure/repository/ProductReadModelRepositoryImpl.java`** + +```java +@Repository +@RequiredArgsConstructor +public class ProductReadModelRepositoryImpl implements ProductReadModelRepository { + private final ProductReadModelJpaRepository jpaRepository; + // ๊ฐ ๋ฉ”์„œ๋“œ: Entity ๋ณ€ํ™˜ ํ›„ JPA ํ˜ธ์ถœ +} +``` + +### 1-5. ์ˆ˜์ • ํŒŒ์ผ โ€” ๋™๊ธฐํ™” ๋ฐฐ์„  + +**`ProductCommandService.java`** โ€” `ProductReadModelRepository` ์˜์กด์„ฑ ์ถ”๊ฐ€: + +| ๋ฉ”์„œ๋“œ | Read Model ๋™๊ธฐํ™” | +|--------|------------------| +| `createProduct()` | Facade์—์„œ brandName๊ณผ ํ•จ๊ป˜ `syncReadModel()` ํ˜ธ์ถœ | +| `updateProduct()` | Facade์—์„œ brandName๊ณผ ํ•จ๊ป˜ `syncReadModel()` ํ˜ธ์ถœ | +| `deleteProduct()` | `readModelRepository.delete(productId)` | +| `increaseLikeCount()` | `readModelRepository.increaseLikeCount(productId)` | +| `decreaseLikeCount()` | `readModelRepository.decreaseLikeCount(productId)` | +| `decreaseStock()` | `readModelRepository.updateStock(productId, newStock)` | + +```java +// ProductCommandService โ€” ์‹ ๊ทœ ๋ฉ”์„œ๋“œ +// 7. Read Model ๋™๊ธฐํ™” (์ƒํ’ˆ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ Facade์—์„œ ํ˜ธ์ถœ) +@Transactional +public void syncReadModel(Product product, String brandName) { + readModelRepository.save(product, brandName); +} + +// 8. Read Model ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ๋™๊ธฐํ™” (๋ธŒ๋žœ๋“œ ์ˆ˜์ • ์‹œ BrandCommandFacade์—์„œ ํ˜ธ์ถœ) +@Transactional +public void syncBrandNameInReadModel(Long brandId, String brandName) { + readModelRepository.updateBrandName(brandId, brandName); +} +``` + +**`ProductCommandFacade.java`** โ€” create/update ์‹œ Read Model sync ์ถ”๊ฐ€: + +```java +// 1. ์ƒํ’ˆ ์ƒ์„ฑ +@Transactional +public AdminProductDetailOutDto createProduct(AdminProductCreateInDto inDto) { + Brand brand = brandQueryService.getBrandById(inDto.brandId()); + Product savedProduct = productCommandService.createProduct(inDto); + // Read Model ๋™๊ธฐํ™” + productCommandService.syncReadModel(savedProduct, brand.getName().value()); + return AdminProductDetailOutDto.from(savedProduct, brand.getName().value()); +} + +// 2. ์ƒํ’ˆ ์ˆ˜์ • +@Transactional +public AdminProductDetailOutDto updateProduct(Long id, AdminProductUpdateInDto inDto) { + Product product = productQueryService.findActiveById(id); + Product updatedProduct = productCommandService.updateProduct(product, inDto); + Brand brand = brandQueryService.getBrandById(updatedProduct.getBrandId()); + // Read Model ๋™๊ธฐํ™” + productCommandService.syncReadModel(updatedProduct, brand.getName().value()); + return AdminProductDetailOutDto.from(updatedProduct, brand.getName().value()); +} +``` + +**`BrandCommandFacade.java`** โ€” ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ Read Model ๋™๊ธฐํ™”: + +```java +// ๊ธฐ์กด ์˜์กด์„ฑ์— ์ถ”๊ฐ€ +private final ProductCommandService productCommandService; // ๊ฐ™์€ BC (catalog) + +// updateBrand() ์ˆ˜์ • +@Transactional +public AdminBrandDetailOutDto updateBrand(Long id, AdminBrandUpdateInDto inDto) { + Brand brand = brandQueryService.getBrandById(id); + Brand updatedBrand = brandCommandService.updateBrand(brand, inDto); + // ์ƒํ’ˆ Read Model์˜ brand_name ์ผ๊ด„ ๋™๊ธฐํ™” + productCommandService.syncBrandNameInReadModel(id, updatedBrand.getName().value()); + return AdminBrandDetailOutDto.from(updatedBrand); +} +``` + +### 1-6. ์ˆ˜์ • ํŒŒ์ผ โ€” ์ฟผ๋ฆฌ ์ „ํ™˜ + +**`ProductQuerydslRepository.java`** โ€” `product_read_model` ํ…Œ์ด๋ธ”์—์„œ ์กฐํšŒํ•˜๋„๋ก ๋ณ€๊ฒฝ: + +```java +// AS-IS: products LEFT JOIN brands +QProductEntity product = QProductEntity.productEntity; +QBrandEntity brand = QBrandEntity.brandEntity; +query.from(product).leftJoin(brand).on(brand.id.eq(product.brandId)) + +// TO-BE: product_read_model (JOIN ์—†์Œ) +QProductReadModelEntity readModel = QProductReadModelEntity.productReadModelEntity; +query.from(readModel) + .where(readModel.deletedAt.isNull()) + .select(Projections.constructor(ProductOutDto.class, + readModel.id, readModel.brandId, readModel.brandName, + readModel.name, readModel.price, readModel.stock, readModel.likeCount)) +``` + +- JOIN ์ œ๊ฑฐ โ†’ ๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT +- `brand.name` โ†’ `readModel.brandName` (๋น„์ •๊ทœํ™” ์ปฌ๋Ÿผ) +- ๊ด€๋ฆฌ์ž ์ฟผ๋ฆฌ(`searchAdminProducts`)๋„ ๋™์ผํ•˜๊ฒŒ Read Model์—์„œ ์กฐํšŒ + +### 1-7. EXPLAIN ์ „ํ›„ ๋น„๊ต ํ…Œ์ŠคํŠธ + +**`src/benchmark/.../infrastructure/ProductIndexPerformanceTest.java`** + +```java +@SpringBootTest +@ActiveProfiles("test") // ddl-auto: create โ†’ Read Model ์ธ๋ฑ์Šค ์ž๋™ ์ƒ์„ฑ +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +class ProductIndexPerformanceTest { + + @Autowired EntityManager entityManager; + @Autowired DatabaseCleanUp databaseCleanUp; + + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); } + + // @BeforeEach: 50๊ฐœ ๋ธŒ๋žœ๋“œ + 100,000๊ฐœ ์ƒํ’ˆ + Read Model bulk insert +} +``` + +**EXPLAIN ๋น„๊ต ์ ˆ์ฐจ**: + +``` +1. ๋ฐ์ดํ„ฐ ์ค€๋น„ + - brands: 50๊ฐœ + - products: 100,000๊ฐœ (๋ธŒ๋žœ๋“œ๋ณ„ ๋žœ๋ค ๋ถ„ํฌ, ๊ฐ€๊ฒฉ/์ข‹์•„์š” ๋žœ๋ค) + - product_read_model: products + brand_name JOIN ๊ฒฐ๊ณผ INSERT + +2. AS-IS ์ธก์ • (products LEFT JOIN brands, ์ธ๋ฑ์Šค ์—†์Œ) + - DROP INDEX on products (PK ์™ธ ์ธ๋ฑ์Šค ์—†๋Š” ์ƒํƒœ ํ™•์ธ) + - 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„ EXPLAIN ANALYZE ์‹คํ–‰ + - ๊ฒฐ๊ณผ ๋กœ๊น…: type, rows, Extra, actual time + +3. TO-BE ์ธก์ • (product_read_model, ์ธ๋ฑ์Šค ์žˆ์Œ) + - Read Model ํ…Œ์ด๋ธ”์— @Table ์ธ๋ฑ์Šค ์ž๋™ ์ƒ์„ฑ๋จ + - 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค๋ณ„ EXPLAIN ANALYZE ์‹คํ–‰ (JOIN ์—†๋Š” ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ฟผ๋ฆฌ) + - ๊ฒฐ๊ณผ ๋กœ๊น…: type, rows, Extra, actual time + +4. ์ „ํ›„ ๋น„๊ต ์ถœ๋ ฅ +``` + +**6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค**: + +| # | brandId | ์ •๋ ฌ | AS-IS (์˜ˆ์ƒ) | TO-BE (๋ชฉํ‘œ) | +|---|:---:|---|---|---| +| 1 | X | LATEST | Full Scan + JOIN + filesort | range scan, filesort ํ™•์ธ ํ•„์š” | +| 2 | X | PRICE_ASC | Full Scan + JOIN + filesort | range scan, filesort ํ™•์ธ ํ•„์š” | +| 3 | X | LIKES_DESC | Full Scan + JOIN + filesort | range scan, filesort ํ™•์ธ ํ•„์š” | +| 4 | O | LATEST | Full Scan + JOIN + filesort | ref/range, filesort ์—†์Œ | +| 5 | O | PRICE_ASC | Full Scan + JOIN + filesort | ref/range, filesort ์—†์Œ | +| 6 | O | LIKES_DESC | Full Scan + JOIN + filesort | ref/range, filesort ์—†์Œ | + +> **no-brand ์ฟผ๋ฆฌ(#1,#2,#3)**: ์ธ๋ฑ์Šค `(brand_id, deleted_at, sort_col)`์—์„œ ์„ ๋‘ ์ปฌ๋Ÿผ `brand_id`๊ฐ€ ์ฟผ๋ฆฌ์— ์—†์œผ๋ฏ€๋กœ ์ธ๋ฑ์Šค ํ™œ์šฉ ๋ถˆ๊ฐ€ โ†’ ๋ณ„๋„ 2-column ์ธ๋ฑ์Šค `(deleted_at, sort_col)` ํ•„์š”. +> +> **์ปฌ๋Ÿผ ์ˆœ์„œ ์›์น™**: ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ `brand_id`(์ˆ˜์‹ญ~์ˆ˜๋ฐฑ)๋ฅผ `deleted_at`(2๊ฐ’: NULL/timestamp)๋ณด๋‹ค ์•ž์— ๋ฐฐ์น˜. ๋‘ ์ปฌ๋Ÿผ ๋ชจ๋‘ equality ์กฐ๊ฑด์ด๋ฏ€๋กœ ์ธ๋ฑ์Šค ํƒ์ƒ‰ ๊ฒฐ๊ณผ๋Š” ๋™์ผํ•˜๋‚˜, B-tree fan-out์ด ๋” ๊ท ๋“ฑํ•ด์ ธ ์ธ๋ฑ์Šค ํšจ์œจ์ด ํ–ฅ์ƒ๋จ. + +### 1-8. DDL Migration ์Šคํฌ๋ฆฝํŠธ + +**`round5-docs/migration/V5__add_product_read_model.sql`** + +```sql +-- Read Model ํ…Œ์ด๋ธ” ์ƒ์„ฑ +CREATE TABLE product_read_model ( + id BIGINT PRIMARY KEY, + brand_id BIGINT NOT NULL, + brand_name VARCHAR(100), + name VARCHAR(200) NOT NULL, + price DECIMAL(12,2) NOT NULL, + stock BIGINT NOT NULL, + description VARCHAR(1000), + like_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP +); + +-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค (์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘) +CREATE INDEX idx_read_brand_deleted_created + ON product_read_model (brand_id, deleted_at, created_at); +CREATE INDEX idx_read_brand_deleted_price + ON product_read_model (brand_id, deleted_at, price); +CREATE INDEX idx_read_brand_deleted_likecount + ON product_read_model (brand_id, deleted_at, like_count); + +-- ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ +INSERT INTO product_read_model (id, brand_id, brand_name, name, price, stock, description, like_count, created_at, updated_at, deleted_at) +SELECT p.id, p.brand_id, b.name, p.name, p.price, p.stock, p.description, p.like_count, p.created_at, p.updated_at, p.deleted_at +FROM products p +LEFT JOIN brands b ON b.id = p.brand_id; +``` + +--- + +## Task 2: ์บ์‹œ ์ธํ”„๋ผ (ProductCacheManager + CacheLock) + +**๋ชฉํ‘œ**: Redis ์บ์‹œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ์ปดํฌ๋„ŒํŠธ + ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ. Redis ์žฅ์•  ๊ฒฉ๋ฆฌ + TTL jitter + PER + ๋กœ์ปฌ ๋ฝ +**์ฒดํฌ๋ฆฌ์ŠคํŠธ ๋Œ€์‘**: [Cache] 5, 6๋ฒˆ ๊ธฐ๋ฐ˜ + +### 2-1. ์„ค๊ณ„ ๊ฒฐ์ • + +| ๊ฒฐ์ • | ๊ทผ๊ฑฐ | +|------|------| +| `RedisTemplate` + `ObjectMapper` | ๊ธฐ์กด modules/redis ์ˆ˜์ • ๋ถˆํ•„์š”, ์ˆœ์ˆ˜ JSON | +| ์ฝ๊ธฐ: `defaultRedisTemplate` (REPLICA_PREFERRED) | master-replica ํ† ํด๋กœ์ง€ ํ™œ์šฉ | +| ์“ฐ๊ธฐ/์‚ญ์ œ: `masterRedisTemplate` (`@Qualifier REDIS_TEMPLATE_MASTER`) | ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ | +| TTL jitter: `TTL + random(0, TTL * 0.1)` | multi-key ๋™์‹œ ๋งŒ๋ฃŒ ๋ฐฉ์–ด (thundering herd) | +| ๋ชจ๋“  ๋ฉ”์„œ๋“œ try-catch | Redis ์žฅ์•  โ†’ ์„œ๋น„์Šค ์žฅ์•  ์ „ํŒŒ ์ฐจ๋‹จ | +| **Port ์ธํ„ฐํŽ˜์ด์Šค ์—†์Œ** | ArchUnit์— Service โ†’ Infrastructure ์ฐจ๋‹จ ๊ทœ์น™ ์—†์Œ. ๊ณผ์ œ ์˜ˆ์‹œ๋„ Service์—์„œ RedisTemplate ์ง์ ‘ ์‚ฌ์šฉ | +| **CacheLock ์ธํ„ฐํŽ˜์ด์Šค** + `LocalCacheLock`(`@Primary`) | single-key ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์–ด. ํ–ฅํ›„ ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ `RedisCacheLock`์œผ๋กœ ๊ต์ฒด ๊ฐ€๋Šฅ | +| **PER (Probabilistic Early Refresh)** | ์บ์‹œ ๋งŒ๋ฃŒ ์ž์ฒด๋ฅผ ์˜ˆ๋ฐฉ. TTL ์ž„๋ฐ• ์‹œ ํ™•๋ฅ ์ ์œผ๋กœ ๋ฏธ๋ฆฌ ๊ฐฑ์‹  | + +### 2-1-1. ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ์ „๋žต (3๊ณ„์ธต) + +``` +[์š”์ฒญ] โ†’ ์บ์‹œ ์กฐํšŒ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ ํžˆํŠธ + TTL ์—ฌ์œ  โ”‚โ”€โ”€โ†’ ๋ฐ”๋กœ ๋ฐ˜ํ™˜ + โ”‚ ํžˆํŠธ + TTL ์ž„๋ฐ• โ”‚โ”€โ”€โ†’ ๋ฐ˜ํ™˜ + ๋น„๋™๊ธฐ ๊ฐฑ์‹  โ† (C: PER โ€” ๋งŒ๋ฃŒ ์˜ˆ๋ฐฉ) + โ”‚ ๋ฏธ์Šค โ”‚โ”€โ”€โ†’ key-level ๋กœ์ปฌ ๋ฝ โ† (B: Local Mutex โ€” ์ค‘๋ณต ์กฐํšŒ ๋ฐฉ์ง€) + โ”‚ Redis ์žฅ์•  โ”‚โ”€โ”€โ†’ DB ์งํ–‰ (try-catch) โ† ์žฅ์•  ๊ฒฉ๋ฆฌ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +| ๊ณ„์ธต | ๋ฐฉ์–ด ๋Œ€์ƒ | ๊ตฌํ˜„ | +|------|-----------|------| +| **TTL jitter** | multi-key ๋™์‹œ ๋งŒ๋ฃŒ | TTL + random(0, TTL * 0.1) | +| **PER** | single-key ๋งŒ๋ฃŒ ์ž์ฒด๋ฅผ ์˜ˆ๋ฐฉ | TTL ๋‚จ์€ ์‹œ๊ฐ„ < threshold ์‹œ ํ™•๋ฅ ์  ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹  | +| **Local Mutex** | ๋งŒ๋ฃŒ ํ›„ DB ์ค‘๋ณต ์กฐํšŒ (1๊ฐœ key์— 100๋ช… ๋™์‹œ miss) | `CacheLock` interface + `LocalCacheLock`(`@Primary`) | +| **try-catch** | Redis ์žฅ์•  ์‹œ ์„œ๋น„์Šค ์ •์ƒ ๋™์ž‘ | ๋ชจ๋“  ์บ์‹œ ๋ฉ”์„œ๋“œ ์˜ˆ์™ธ ๊ฒฉ๋ฆฌ โ†’ DB fallback | +| **์ธ๋ฑ์Šค** | ์ตœ์ข… ์•ˆ์ „๋ง | DB ์งํ–‰ํ•ด๋„ Read Model ์ธ๋ฑ์Šค๋กœ ๋น ๋ฅธ ์‘๋‹ต | + +### 2-2. ์‹ ๊ทœ ํŒŒ์ผ โ€” CacheLock (์ „๋žต ํŒจํ„ด) + +**`apps/.../catalog/product/infrastructure/cache/CacheLock.java`** (์ธํ„ฐํŽ˜์ด์Šค) + +```java +/** + * ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์ง€์šฉ key-level ๋ฝ + * - ๊ฐ™์€ key์— ๋Œ€ํ•œ ๋™์‹œ DB ์กฐํšŒ๋ฅผ 1ํšŒ๋กœ ์ œํ•œ + * - ๊ตฌํ˜„์ฒด: LocalCacheLock (@Primary), RedisCacheLock (๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜์šฉ) + */ +public interface CacheLock { + T executeWithLock(String key, Supplier loader); +} +``` + +**`apps/.../catalog/product/infrastructure/cache/LocalCacheLock.java`** (`@Primary`) + +```java +/** + * JVM ๋กœ์ปฌ key-level ์บ์‹œ ๋ฝ + * - ConcurrentHashMap + synchronized๋กœ ๊ฐ™์€ key ์š”์ฒญ๋งŒ ์ง๋ ฌํ™” + * - ๋‹ค๋ฅธ key ์š”์ฒญ์€ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ (key ๋‹จ์œ„ ์„ธ๋ฐ€ํ•œ ๋ฝ) + * - ๋‹จ์ผ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ RedisCacheLock์œผ๋กœ @Primary ์ด๋™ + */ +@Primary +@Component +public class LocalCacheLock implements CacheLock { + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + @Override + public T executeWithLock(String key, Supplier loader) { + Object lock = locks.computeIfAbsent(key, k -> new Object()); + synchronized (lock) { + try { + return loader.get(); + } finally { + locks.remove(key); + } + } + } +} +``` + +**`apps/.../catalog/product/infrastructure/cache/RedisCacheLock.java`** (๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜์šฉ) + +```java +/** + * Redis SETNX ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ์บ์‹œ ๋ฝ + * - ๋ถ„์‚ฐ ํ™˜๊ฒฝ(multi-JVM)์—์„œ ์‚ฌ์šฉ + * - ํ˜„์žฌ๋Š” ๋Œ€๊ธฐ ์ƒํƒœ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ @Primary ์ด๋™ + */ +@Component +public class RedisCacheLock implements CacheLock { + private final RedisTemplate redisTemplate; + + @Override + public T executeWithLock(String key, Supplier loader) { + String lockKey = key + ":lock"; + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); + try { + if (Boolean.TRUE.equals(acquired)) { + return loader.get(); + } else { + Thread.sleep(50); + return loader.get(); // ๋Œ€๊ธฐ ํ›„ ์žฌ์‹œ๋„ (์บ์‹œ ํžˆํŠธ ๊ธฐ๋Œ€) + } + } finally { + if (Boolean.TRUE.equals(acquired)) { + redisTemplate.delete(lockKey); + } + } + } +} +``` + +### 2-3. ์‹ ๊ทœ ํŒŒ์ผ โ€” ProductCacheManager + +**`apps/.../catalog/product/infrastructure/cache/ProductCacheManager.java`** + +```java +/** + * ์ƒํ’ˆ ์บ์‹œ ๊ด€๋ฆฌ์ž + * - Redis ๊ธฐ๋ฐ˜ Cache-Aside ํŒจํ„ด ์ง€์› + * - ๋ชจ๋“  ๋ฉ”์„œ๋“œ๋Š” Redis ์žฅ์•  ์‹œ ์˜ˆ์™ธ๋ฅผ ๊ฒฉ๋ฆฌํ•˜๊ณ  ๋กœ๊น…๋งŒ ์ˆ˜ํ–‰ + * - ์ฝ๊ธฐ: replica-preferred, ์“ฐ๊ธฐ/์‚ญ์ œ: master + * - ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ: CacheLock + PER (Probabilistic Early Refresh) + * + * 1. get(key, Class) โ€” ๋‹จ์ˆœ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + * 2. get(key, TypeReference) โ€” ์ œ๋„ค๋ฆญ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + * 3. put(key, value, ttl) โ€” ์บ์‹œ ์ €์žฅ (TTL jitter ํฌํ•จ) + * 4. evict(key) โ€” ๋‹จ์ผ ํ‚ค ์‚ญ์ œ + * 5. evictByPattern(pattern) โ€” SCAN ๊ธฐ๋ฐ˜ ํŒจํ„ด ์‚ญ์ œ + * 6. getOrLoad(key, type, ttl, loader) โ€” Cache-Aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ (CacheLock + double-check) + * 7. getOrLoadWithPer(key, type, ttl, loader) โ€” getOrLoad + PER (TTL ์ž„๋ฐ• ์‹œ ํ™•๋ฅ ์  ๊ฐฑ์‹ ) + */ +@Slf4j +@Component +public class ProductCacheManager { + + private final RedisTemplate readTemplate; // default (replica-preferred) + private final RedisTemplate writeTemplate; // master + private final ObjectMapper objectMapper; + private final CacheLock cacheLock; + + // --- ๊ธฐ๋ณธ ๋ฉ”์„œ๋“œ --- + // get(): Redis ์กฐํšŒ ์‹คํŒจ ์‹œ Optional.empty() ๋ฐ˜ํ™˜ โ†’ DB fallback + // put(): TTL์— jitter(0~10%) ์ถ”๊ฐ€ํ•˜์—ฌ ๋™์‹œ ๋งŒ๋ฃŒ ๋ฐฉ์ง€ + // evict(): ๋‹จ์ผ ํ‚ค ์‚ญ์ œ. ์‹คํŒจ ์‹œ ๋ฌด์‹œ โ†’ TTL ๋งŒ๋ฃŒ์— ์˜์กด + // evictByPattern(): SCAN ๊ธฐ๋ฐ˜ non-blocking ํŒจํ„ด ์‚ญ์ œ + + // --- ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ๋ฉ”์„œ๋“œ --- + + /** + * 6. Cache-Aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + * - CacheLock์œผ๋กœ ๊ฐ™์€ key์— ๋Œ€ํ•œ ๋™์‹œ DB ์กฐํšŒ๋ฅผ 1ํšŒ๋กœ ์ œํ•œ + * - double-check: ๋ฝ ๋Œ€๊ธฐ ํ›„ ์บ์‹œ ์žฌ์กฐํšŒ (๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ๊ฐ€ ์ €์žฅํ–ˆ์„ ์ˆ˜ ์žˆ์Œ) + */ + public T getOrLoad(String key, Class type, Duration ttl, Supplier loader) { + // ์บ์‹œ ์กฐํšŒ + Optional cached = get(key, type); + if (cached.isPresent()) return cached.get(); + + // ์บ์‹œ ๋ฏธ์Šค โ†’ ๋ฝ ํš๋“ ํ›„ DB ์กฐํšŒ (1ํšŒ๋งŒ) + return cacheLock.executeWithLock(key, () -> { + // double-check (๋Œ€๊ธฐ ์ค‘ ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ๊ฐ€ ์บ์‹œ ์ €์žฅํ–ˆ์„ ์ˆ˜ ์žˆ์Œ) + Optional doubleCheck = get(key, type); + if (doubleCheck.isPresent()) return doubleCheck.get(); + + // DB ์กฐํšŒ + ์บ์‹œ ์ €์žฅ + T value = loader.get(); + put(key, value, ttl); + return value; + }); + } + + /** + * 7. Cache-Aside + PER (Probabilistic Early Refresh) + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + * - TTL ์ž”์—ฌ ์‹œ๊ฐ„์ด threshold ์ดํ•˜์ด๋ฉด ํ™•๋ฅ ์ ์œผ๋กœ ๊ฐฑ์‹  โ†’ ์บ์‹œ ๋งŒ๋ฃŒ ์ž์ฒด๋ฅผ ์˜ˆ๋ฐฉ + * - PER์„ ๋šซ๊ณ  ๋งŒ๋ฃŒ ๋ฐœ์ƒ ์‹œ CacheLock์ด DB ์ค‘๋ณต ์กฐํšŒ ๋ฐฉ์ง€ + */ + public T getOrLoadWithPer(String key, Class type, Duration ttl, Supplier loader) { + Optional cached = get(key, type); + if (cached.isPresent()) { + // PER: TTL ์ž”์—ฌ ์‹œ๊ฐ„ ํ™•์ธ โ†’ ์ž„๋ฐ• ์‹œ ํ™•๋ฅ ์  ๊ฐฑ์‹  + if (shouldEarlyRefresh(key, ttl)) { + CompletableFuture.runAsync(() -> { + T fresh = loader.get(); + put(key, fresh, ttl); + }); + } + return cached.get(); + } + + // ์บ์‹œ ๋ฏธ์Šค โ†’ ๋ฝ + double-check + return cacheLock.executeWithLock(key, () -> { + Optional doubleCheck = get(key, type); + if (doubleCheck.isPresent()) return doubleCheck.get(); + + T value = loader.get(); + put(key, value, ttl); + return value; + }); + } + + /** + * PER ํŒ์ •: TTL์˜ ๋งˆ์ง€๋ง‰ 20% ๊ตฌ๊ฐ„์—์„œ ํ™•๋ฅ ์  ๊ฐฑ์‹  + * - ๋‚จ์€ ์‹œ๊ฐ„์ด ์ ์„์ˆ˜๋ก ๊ฐฑ์‹  ํ™•๋ฅ  ์ฆ๊ฐ€ + * - ์˜ˆ: TTL 10๋ถ„, ๋‚จ์€ ์‹œ๊ฐ„ 1๋ถ„ โ†’ ๊ฐฑ์‹  ํ™•๋ฅ  ~50% + */ + private boolean shouldEarlyRefresh(String key, Duration baseTtl) { + try { + Long remainMs = readTemplate.getExpire(key, TimeUnit.MILLISECONDS); + if (remainMs == null || remainMs <= 0) return false; + + long thresholdMs = baseTtl.toMillis() / 5; // 20% ๊ตฌ๊ฐ„ + if (remainMs > thresholdMs) return false; + + // ๋‚จ์€ ์‹œ๊ฐ„์ด ์ ์„์ˆ˜๋ก ํ™•๋ฅ  ์ฆ๊ฐ€ (์„ ํ˜•) + double probability = 1.0 - ((double) remainMs / thresholdMs); + return ThreadLocalRandom.current().nextDouble() < probability; + } catch (Exception e) { + return false; + } + } +} +``` + +### 2-4. ํ…Œ์ŠคํŠธ + +**`src/test/.../infrastructure/cache/ProductCacheManagerTest.java`** (ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ): +1. put โ†’ get(Class): ProductDetailOutDto ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” +2. put โ†’ get(TypeReference): ProductPageOutDto ์ œ๋„ค๋ฆญ ํƒ€์ž… ๊ฒ€์ฆ +3. BigDecimal ์ •๋ฐ€๋„: `compareTo` ๊ธฐ์ค€ ๊ฒ€์ฆ +4. evict: ์ €์žฅ ํ›„ ์‚ญ์ œ โ†’ Optional.empty() +5. evictByPattern: `products:list:*` ํŒจํ„ด์œผ๋กœ ์—ฌ๋Ÿฌ ํ‚ค ์ผ๊ด„ ์‚ญ์ œ +6. TTL: base ยฑ 10% ๋ฒ”์œ„ ๊ฒ€์ฆ +7. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ‚ค ์กฐํšŒ โ†’ Optional.empty() (์˜ˆ์™ธ ์—†์Œ) +8. null description ํ•„๋“œ โ†’ ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ์ •์ƒ ๋™์ž‘ +9. getOrLoad: ์บ์‹œ ๋ฏธ์Šค โ†’ loader 1ํšŒ ํ˜ธ์ถœ + ์บ์‹œ ์ €์žฅ +10. getOrLoad: ์บ์‹œ ํžˆํŠธ โ†’ loader ๋ฏธํ˜ธ์ถœ + +**`src/test/.../infrastructure/cache/CacheStampedeTest.java`** (์Šคํƒฌํ”ผ๋“œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ): +1. **single-key ์Šคํƒฌํ”ผ๋“œ**: ์บ์‹œ ๋งŒ๋ฃŒ ํ›„ 100 concurrent ์š”์ฒญ โ†’ loader ํ˜ธ์ถœ ํšŸ์ˆ˜ ๊ฒ€์ฆ (์ด์ƒ: 1ํšŒ) +2. **multi-key ์Šคํƒฌํ”ผ๋“œ**: TTL jitter ์ ์šฉ๋œ 100๊ฐœ ํ‚ค โ†’ ๋™์‹œ ๋งŒ๋ฃŒ ๋ถ„์‚ฐ ๊ฒ€์ฆ +3. **PER ๋™์ž‘**: TTL ์ž„๋ฐ• ์‹œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹  ๋ฐœ์ƒ โ†’ ํ›„์† ์š”์ฒญ์€ ๊ฐฑ์‹ ๋œ ์บ์‹œ ํžˆํŠธ +4. **Redis ์žฅ์•  ์‹œ**: ๋ชจ๋“  ์š”์ฒญ์ด DB fallback โ†’ ์„œ๋น„์Šค ์ •์ƒ ๋™์ž‘ (loader ๋งค๋ฒˆ ํ˜ธ์ถœ) + +**`src/test/.../infrastructure/cache/LocalCacheLockTest.java`** (๋‹จ์œ„ ํ…Œ์ŠคํŠธ): +1. ๊ฐ™์€ key 100 concurrent โ†’ loader 1ํšŒ๋งŒ ์‹คํ–‰, ๋‚˜๋จธ์ง€๋Š” ๋Œ€๊ธฐ ํ›„ ๊ฒฐ๊ณผ ๊ณต์œ  +2. ๋‹ค๋ฅธ key โ†’ ๋ณ‘๋ ฌ ์‹คํ–‰ (์„œ๋กœ ๋ธ”๋กœํ‚นํ•˜์ง€ ์•Š์Œ) +3. loader ์˜ˆ์™ธ โ†’ ๋ฝ ์ •์ƒ ํ•ด์ œ, ์˜ˆ์™ธ ์ „ํŒŒ + +--- + +## Task 3: ์บ์‹œ ์ ์šฉ (์ƒ์„ธ + ๋ชฉ๋ก + ๋ฌดํšจํ™”) + +**๋ชฉํ‘œ**: ์ƒ์„ธ/๋ชฉ๋ก API์— Cache-Aside ํŒจํ„ด ์ ์šฉ + ๋ชจ๋“  ๋ณ€๊ฒฝ ์‹œ ์บ์‹œ ๋ฌดํšจํ™” +**์ฒดํฌ๋ฆฌ์ŠคํŠธ ๋Œ€์‘**: [Cache] 5, 6๋ฒˆ + +### 3-1. ์ƒ์„ธ ์บ์‹œ ์„ค๊ณ„ + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ์บ์‹œ ํ‚ค | `product:{productId}` | +| ์บ์‹œ ๋ ˆ์ด์–ด | **Facade ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜** (์ƒ์„ธ DTO = Product + BrandName 2๊ฐœ ๋„๋ฉ”์ธ ์กฐํ•ฉ) | +| ์บ์‹œ ๊ฐ’ | `ProductDetailOutDto` (JSON) | +| TTL | 10๋ถ„ + jitter | +| ๋ฌดํšจํ™” | ์ƒํ’ˆ ๋ณ€๊ฒฝ/์‚ญ์ œ/์ข‹์•„์š” ๋ณ€๊ฒฝ ์‹œ `product:{id}` ์‚ญ์ œ + TTL ์•ˆ์ „๋ง | + +**Facade ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ๋ฐฉ์‹ (ArchUnit ์ค€์ˆ˜)**: + +Facade๋Š” ์บ์‹œ ์ธํ”„๋ผ์— ์ง์ ‘ ์ ‘๊ทผํ•  ์ˆ˜ ์—†์œผ๋ฏ€๋กœ, Service์˜ `getOrLoadProductDetail()` ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. +Service ๋‚ด๋ถ€์—์„œ `ProductCacheManager.getOrLoadWithPer()`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์บ์‹œ ์กฐํšŒ + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + PER์„ ์ผ๊ด„ ์ฒ˜๋ฆฌ: + +```java +// ProductQueryService โ€” ์ƒ์„ธ ์บ์‹œ ๋ฉ”์„œ๋“œ (PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ํฌํ•จ) +public ProductDetailOutDto getOrLoadProductDetail(Long productId, Supplier loader) { + String cacheKey = "product:" + productId; + return productCacheManager.getOrLoadWithPer(cacheKey, ProductDetailOutDto.class, Duration.ofMinutes(10), loader); +} +``` + +```java +// ProductQueryFacade โ€” cache-aside ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ +@Transactional(readOnly = true) +public ProductDetailOutDto getProduct(Long id) { + return productQueryService.getOrLoadProductDetail(id, () -> { + // ์บ์‹œ ๋ฏธ์Šค ์‹œ DB ์กฐํšŒ (loader) + Product product = productQueryService.findActiveById(id); + Brand brand = brandQueryService.getBrandById(product.getBrandId()); + return ProductDetailOutDto.from(product, brand.getName().value()); + }); +} +``` + +- ์บ์‹œ ํžˆํŠธ + TTL ์—ฌ์œ : DB ํ˜ธ์ถœ 0ํšŒ, ๋ฐ”๋กœ ๋ฐ˜ํ™˜ +- ์บ์‹œ ํžˆํŠธ + TTL ์ž„๋ฐ•: ๋ฐ˜ํ™˜ + ๋น„๋™๊ธฐ ๊ฐฑ์‹  (PER) +- ์บ์‹œ ๋ฏธ์Šค: ๋กœ์ปฌ ๋ฝ์œผ๋กœ 1๋ช…๋งŒ DB ์กฐํšŒ, ๋‚˜๋จธ์ง€ ๋Œ€๊ธฐ ํ›„ ์บ์‹œ ํžˆํŠธ (์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ) +- Redis ์žฅ์• : try-catch โ†’ loader ์‹คํ–‰ โ†’ DB ์งํ–‰ (์„œ๋น„์Šค ์ •์ƒ ๋™์ž‘) +- Facade๋Š” Service ๋ฉ”์„œ๋“œ๋งŒ ํ˜ธ์ถœ โ†’ ArchUnit ํ†ต๊ณผ + +### 3-2. ๋ชฉ๋ก ์บ์‹œ ์„ค๊ณ„ + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ์บ์‹œ ํ‚ค | `products:list:{brandId\|all}:{sortType\|LATEST}:{page}:{size}` | +| ์บ์‹œ ๋ ˆ์ด์–ด | **Service ๋‚ด๋ถ€** (๋‹จ์ผ ๋„๋ฉ”์ธ QueryPort ์กฐํšŒ, Facade๋Š” ๋‹จ์ˆœ ์œ„์ž„) | +| ์บ์‹œ ๊ฐ’ | `ProductPageOutDto` (JSON) | +| TTL | 5๋ถ„ + jitter | +| ๋ฌดํšจํ™” | SCAN ๊ธฐ๋ฐ˜ `products:list:*` ํŒจํ„ด ์‚ญ์ œ + TTL ์•ˆ์ „๋ง | +| ๊ด€๋ฆฌ์ž ๊ฒ€์ƒ‰ | **์บ์‹œ ๋ฏธ์ ์šฉ** | + +```java +// ProductQueryService โ€” searchProducts() ์บ์‹œ ์ ์šฉ (PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ) +@Transactional(readOnly = true) +public ProductPageOutDto searchProducts(Long brandId, ProductSortType sortType, int page, int size) { + String cacheKey = buildListCacheKey(brandId, sortType, page, size); + + return productCacheManager.getOrLoadWithPer(cacheKey, ProductPageOutDto.class, Duration.ofMinutes(5), () -> { + // ์บ์‹œ ๋ฏธ์Šค ์‹œ DB ์กฐํšŒ (loader) + ProductSearchCriteria criteria = new ProductSearchCriteria(brandId, sortType); + PageResult result = productQueryPort.searchProducts(criteria, new PageCriteria(page, size)); + return ProductPageOutDto.from(result); + }); +} + +// searchAdminProducts(): ๋ณ€๊ฒฝ ์—†์Œ (์บ์‹œ ๋ฏธ์ ์šฉ) + +private String buildListCacheKey(Long brandId, ProductSortType sortType, int page, int size) { + String brandKey = brandId != null ? String.valueOf(brandId) : "all"; + String sortKey = sortType != null ? sortType.name() : "LATEST"; + return String.format("products:list:%s:%s:%d:%d", brandKey, sortKey, page, size); +} +``` + +### 3-3. ์บ์‹œ ๋ฌดํšจํ™” + +**`ProductCommandService.java`** โ€” `ProductCacheManager` ์˜์กด์„ฑ ์ถ”๊ฐ€, ๊ฐ mutation ๋ฉ”์„œ๋“œ ๋์— ์บ์‹œ ๋ฌดํšจํ™”: + +| ๋ฉ”์„œ๋“œ | ์ƒ์„ธ ์บ์‹œ | ๋ชฉ๋ก ์บ์‹œ | +|--------|:---------:|:---------:| +| `createProduct()` | - (์‹ ๊ทœ ์ƒํ’ˆ) | `evictByPattern("products:list:*")` | +| `updateProduct()` | Facade์—์„œ ์ฒ˜๋ฆฌ (์•„๋ž˜ ์ฐธ๊ณ ) | `evictByPattern("products:list:*")` | +| `deleteProduct()` | Facade์—์„œ ์ฒ˜๋ฆฌ (์•„๋ž˜ ์ฐธ๊ณ ) | `evictByPattern("products:list:*")` | +| `decreaseStock()` | `evict("product:" + id)` | `evictByPattern("products:list:*")` | +| `increaseLikeCount()` | `evict("product:" + id)` | `evictByPattern("products:list:*")` | +| `decreaseLikeCount()` | `evict("product:" + id)` | `evictByPattern("products:list:*")` | + +> `decreaseStock()` ํฌํ•จ ๊ทผ๊ฑฐ: `ProductOutDto`์— `stock` ํ•„๋“œ ํฌํ•จ โ†’ ์žฌ๊ณ  ๋ณ€๊ฒฝ ์‹œ ์บ์‹œ ๋ฌดํšจํ™” ํ•„์š” + +**์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” โ€” Facade์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์„œ๋“œ**: + +`updateProduct()`๊ณผ `deleteProduct()`์€ Facade์—์„œ `productId`๋ฅผ ์•Œ๊ณ  ์žˆ์œผ๋ฏ€๋กœ Service์˜ evict ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ: + +```java +// ProductCommandService โ€” ์บ์‹œ evict ๋ฉ”์„œ๋“œ ๋…ธ์ถœ +public void evictProductDetailCache(Long productId) { + productCacheManager.evict("product:" + productId); +} + +// ProductCommandFacade.updateProduct() +Product updatedProduct = productCommandService.updateProduct(product, inDto); +productCommandService.syncReadModel(updatedProduct, brand.getName().value()); +productCommandService.evictProductDetailCache(id); // ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” + +// ProductCommandFacade.deleteProduct() +productCommandService.deleteProduct(product); +productCommandService.evictProductDetailCache(id); // ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” +``` + +### 3-4. ํ…Œ์ŠคํŠธ + +**`ProductQueryServiceTest.java` ์ˆ˜์ •**: +- `@Mock ProductCacheManager productCacheManager` ์ถ”๊ฐ€, ์ƒ์„ฑ์ž ์ฃผ์ž… ์—…๋ฐ์ดํŠธ +- ์‹ ๊ทœ: `[searchProducts()] ์บ์‹œ ํžˆํŠธ โ†’ ์บ์‹œ๋œ ProductPageOutDto ๋ฐ˜ํ™˜. QueryPort ๋ฏธํ˜ธ์ถœ` +- ์‹ ๊ทœ: `[searchProducts()] ์บ์‹œ ๋ฏธ์Šค โ†’ QueryPort ํ˜ธ์ถœ ํ›„ ์บ์‹œ ์ €์žฅ. ProductPageOutDto ๋ฐ˜ํ™˜` +- ์‹ ๊ทœ: `[searchProducts()] brandId=null โ†’ ์บ์‹œ ํ‚ค์— "all" ์‚ฌ์šฉ` +- ๊ธฐ์กด `searchAdminProducts`: ์บ์‹œ ๋ฏธ์‚ฌ์šฉ verify +- ์‹ ๊ทœ: `[getProductDetailCache()] ์บ์‹œ ํžˆํŠธ โ†’ Optional.of(dto) ๋ฐ˜ํ™˜` +- ์‹ ๊ทœ: `[getProductDetailCache()] ์บ์‹œ ๋ฏธ์Šค โ†’ Optional.empty() ๋ฐ˜ํ™˜` + +**`ProductCommandServiceTest.java` ์ˆ˜์ •**: +- `@Mock ProductCacheManager productCacheManager` + `@Mock ProductReadModelRepository readModelRepository` ์ถ”๊ฐ€ +- ๊ธฐ์กด mutation ํ…Œ์ŠคํŠธ์— ์บ์‹œ eviction verify ์ถ”๊ฐ€ +- ์‹ ๊ทœ: `[syncReadModel()] Read Model ์ €์žฅ ํ˜ธ์ถœ ๊ฒ€์ฆ` +- ์‹ ๊ทœ: `[syncBrandNameInReadModel()] ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ ํ˜ธ์ถœ ๊ฒ€์ฆ` + +**`ProductQueryFacadeTest.java` ์ˆ˜์ •**: +- ์‹ ๊ทœ: `[getProduct()] ์บ์‹œ ํžˆํŠธ โ†’ ์บ์‹œ๋œ ProductDetailOutDto ๋ฐ˜ํ™˜. Service/Brand DB ๋ฏธํ˜ธ์ถœ` +- ์‹ ๊ทœ: `[getProduct()] ์บ์‹œ ๋ฏธ์Šค โ†’ DB ์กฐํšŒ ํ›„ ์บ์‹œ ์ €์žฅ. ProductDetailOutDto ๋ฐ˜ํ™˜` + +**`ProductCommandFacadeTest.java` ์ˆ˜์ •**: +- ์‹ ๊ทœ: `[createProduct()] ์ƒํ’ˆ ์ƒ์„ฑ ํ›„ Read Model ๋™๊ธฐํ™” ํ˜ธ์ถœ ๊ฒ€์ฆ` +- ์‹ ๊ทœ: `[updateProduct()] ์ƒํ’ˆ ์ˆ˜์ • ํ›„ Read Model ๋™๊ธฐํ™” + ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” ํ˜ธ์ถœ ๊ฒ€์ฆ` +- ์‹ ๊ทœ: `[deleteProduct()] ์ƒํ’ˆ ์‚ญ์ œ ํ›„ ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” ํ˜ธ์ถœ ๊ฒ€์ฆ` + +**`BrandCommandFacadeTest.java` ์ˆ˜์ •**: +- ์‹ ๊ทœ: `[updateBrand()] ๋ธŒ๋žœ๋“œ ์ˆ˜์ • ํ›„ Read Model ๋ธŒ๋žœ๋“œ๋ช… ๋™๊ธฐํ™” ํ˜ธ์ถœ ๊ฒ€์ฆ` + +**`ProductControllerE2ETest.java` ์ˆ˜์ •**: +- `@Autowired RedisCleanUp redisCleanUp` + `@AfterEach`์— `redisCleanUp.truncateAll()` ์ถ”๊ฐ€ +- ์‹ ๊ทœ: ์ƒํ’ˆ ์ˆ˜์ • ํ›„ ์ƒ์„ธ ์กฐํšŒ โ†’ ์ˆ˜์ •๋œ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ (์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” ๊ฒ€์ฆ) +- ์‹ ๊ทœ: ์ƒํ’ˆ ์ˆ˜์ • ํ›„ ๋ชฉ๋ก ์กฐํšŒ โ†’ ์ˆ˜์ •๋œ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ (๋ชฉ๋ก ์บ์‹œ ๋ฌดํšจํ™” ๊ฒ€์ฆ) + +--- + +## ํฌ๋กœ์Šค-BC ์บ์‹œ + Read Model ๋ฌดํšจํ™” ํ๋ฆ„ + +``` +ProductLikeCommandFacade.createLike() [engagement BC] + โ†’ ProductLikeCommandService.increaseLikeCount() + โ†’ ProductLikeCountSyncerImpl [ACL โ€” engagement โ†’ catalog] + โ†’ ProductCommandFacade.increaseLikeCount() [catalog BC] + โ†’ ProductCommandService.increaseLikeCount() + โ†’ productCommandRepository.increaseLikeCount(productId) โ† products ์›๋ณธ + โ†’ readModelRepository.increaseLikeCount(productId) โ† Read Model ๋™๊ธฐํ™” + โ†’ productCacheManager.evict("product:" + productId) โ† ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” + โ†’ productCacheManager.evictByPattern("products:list:*") โ† ๋ชฉ๋ก ์บ์‹œ ๋ฌดํšจํ™” +``` + +๊ธฐ์กด ACL ๊ตฌ์กฐ ๊ทธ๋Œ€๋กœ ํ™œ์šฉ. engagement BC ์ฝ”๋“œ ์ˆ˜์ • ๋ถˆํ•„์š”. + +--- + +## ํ•ต์‹ฌ ํŒŒ์ผ ๋ชฉ๋ก + +### ์‹ ๊ทœ ์ƒ์„ฑ +| ํŒŒ์ผ | Task | ์„ค๋ช… | +|------|:----:|------| +| `infrastructure/entity/ProductReadModelEntity.java` | 1 | Read Model JPA ์—”ํ‹ฐํ‹ฐ | +| `infrastructure/jpa/ProductReadModelJpaRepository.java` | 1 | Read Model Spring Data JPA | +| `domain/repository/ProductReadModelRepository.java` | 1 | Read Model ๋™๊ธฐํ™” ์ธํ„ฐํŽ˜์ด์Šค | +| `infrastructure/repository/ProductReadModelRepositoryImpl.java` | 1 | Read Model ๋™๊ธฐํ™” ๊ตฌํ˜„์ฒด | +| `infrastructure/cache/CacheLock.java` | 2 | ์บ์‹œ ๋ฝ ์ธํ„ฐํŽ˜์ด์Šค (์ „๋žต ํŒจํ„ด) | +| `infrastructure/cache/LocalCacheLock.java` | 2 | JVM ๋กœ์ปฌ ๋ฝ ๊ตฌํ˜„์ฒด (`@Primary`) | +| `infrastructure/cache/RedisCacheLock.java` | 2 | Redis ๋ถ„์‚ฐ ๋ฝ ๊ตฌํ˜„์ฒด (๋Œ€๊ธฐ) | +| `infrastructure/cache/ProductCacheManager.java` | 2 | Redis ์บ์‹œ ๊ด€๋ฆฌ์ž (getOrLoad + PER) | +| `test/.../infrastructure/cache/ProductCacheManagerTest.java` | 2 | ์บ์‹œ ์ธํ”„๋ผ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ | +| `test/.../infrastructure/cache/CacheStampedeTest.java` | 2 | ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ | +| `test/.../infrastructure/cache/LocalCacheLockTest.java` | 2 | ๋กœ์ปฌ ๋ฝ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ | +| `test/.../infrastructure/ProductIndexPerformanceTest.java` | 1 | EXPLAIN ์ „ํ›„ ๋น„๊ต ํ…Œ์ŠคํŠธ | +| `round5-docs/migration/V5__add_product_read_model.sql` | 1 | DDL + ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ migration | + +### ์ˆ˜์ • ๋Œ€์ƒ +| ํŒŒ์ผ | Task | ๋ณ€๊ฒฝ ๋‚ด์šฉ | +|------|:----:|----------| +| `ProductCommandService.java` | 1, 3 | ReadModelRepository + CacheManager ์˜์กด์„ฑ ์ถ”๊ฐ€, ๋™๊ธฐํ™”/๋ฌดํšจํ™” | +| `ProductCommandFacade.java` | 1, 3 | create/update ์‹œ syncReadModel + evictDetailCache ํ˜ธ์ถœ | +| `ProductQueryService.java` | 3 | CacheManager ์˜์กด์„ฑ ์ถ”๊ฐ€, ์บ์‹œ ์œ ํ‹ธ ๋ฉ”์„œ๋“œ + ๋ชฉ๋ก cache-aside | +| `ProductQueryFacade.java` | 3 | ์ƒ์„ธ ์บ์‹œ cache-aside ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ | +| `ProductQuerydslRepository.java` | 1 | Read Model ํ…Œ์ด๋ธ”์—์„œ ์กฐํšŒ (JOIN ์ œ๊ฑฐ) | +| `BrandCommandFacade.java` | 1 | ๋ธŒ๋žœ๋“œ ์ˆ˜์ • ์‹œ Read Model ๋ธŒ๋žœ๋“œ๋ช… ๋™๊ธฐํ™” | +| `ProductQueryServiceTest.java` | 3 | Mock ์ฃผ์ž… + ์บ์‹œ ํžˆํŠธ/๋ฏธ์Šค ํ…Œ์ŠคํŠธ | +| `ProductCommandServiceTest.java` | 1, 3 | Mock ์ฃผ์ž… + Read Model ๋™๊ธฐํ™” + ์บ์‹œ ๋ฌดํšจํ™” verify | +| `ProductQueryFacadeTest.java` | 3 | ์ƒ์„ธ ์บ์‹œ ํžˆํŠธ/๋ฏธ์Šค ํ…Œ์ŠคํŠธ | +| `ProductCommandFacadeTest.java` | 1, 3 | Read Model ๋™๊ธฐํ™” + ์บ์‹œ ๋ฌดํšจํ™” verify | +| `BrandCommandFacadeTest.java` | 1 | ๋ธŒ๋žœ๋“œ๋ช… Read Model ๋™๊ธฐํ™” verify | +| `ProductControllerE2ETest.java` | 3 | RedisCleanUp + ์บ์‹œ ๋ฌดํšจํ™” E2E | + +--- + +## ๋ฆฌ์Šคํฌ ๋ฐ ๋Œ€์‘ + +| # | ๋ฆฌ์Šคํฌ | ์‹ฌ๊ฐ๋„ | ๋Œ€์‘ | +|---|--------|:------:|------| +| 1 | **no-brand ์ฟผ๋ฆฌ filesort** โ€” 3-column ์ธ๋ฑ์Šค์—์„œ `brand_id` skip ์‹œ ์ •๋ ฌ ์ธ๋ฑ์Šค ๋ฏธํ™œ์šฉ | ์ค‘ | EXPLAIN ํ…Œ์ŠคํŠธ๋กœ ํ™•์ธ ํ›„, ํ•„์š” ์‹œ 2-column ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| 2 | **Read Model ๋™๊ธฐํ™” ๋ˆ„๋ฝ** โ€” ์ƒˆ mutation ์ถ”๊ฐ€ ์‹œ Read Model sync๋ฅผ ๋น ๋œจ๋ฆฌ๋ฉด ๋ฐ์ดํ„ฐ ๋ถˆ์ผ์น˜ | ์ค‘ | ํ…Œ์ŠคํŠธ์—์„œ sync ํ˜ธ์ถœ ๊ฒ€์ฆ. ํ–ฅํ›„ ๋„๋ฉ”์ธ ์ด๋ฒคํŠธ ์ „ํ™˜ ์‹œ ์ž๋™ํ™” ๊ฐ€๋Šฅ | +| 3 | **TX ์ปค๋ฐ‹ ์ „ ์บ์‹œ ๋ฌดํšจํ™”** โ€” evict ํ›„ ์ปค๋ฐ‹ ์ „ stale ๋ฐ์ดํ„ฐ ์žฌ์บ์‹ฑ ๊ฐ€๋Šฅ | ํ•˜ | TTL 5~10๋ถ„์ด safety net | +| 4 | **๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์บ์‹œ stale** โ€” ๋ชฉ๋ก ์บ์‹œ์— `brandName` ํฌํ•จ๋˜๋‚˜ brand ๋ณ€๊ฒฝ ์‹œ ์บ์‹œ ๋ฏธ๋ฌดํšจํ™” | ํ•˜ | ๋ธŒ๋žœ๋“œ ์ˆ˜์ •์€ ๊ด€๋ฆฌ์ž ์ „์šฉ ๊ทนํžˆ ๋“œ๋ฌธ ์—ฐ์‚ฐ. TTL ์ž์—ฐ ๋งŒ๋ฃŒ๋กœ ์ถฉ๋ถ„ | +| 5 | **BigDecimal ์ง๋ ฌํ™”** | ํ•˜ | `JacksonConfig`์— `WRITE_BIGDECIMAL_AS_PLAIN` ์กด์žฌ. ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆ | +| 6 | **๊ธฐ์กด ํ…Œ์ŠคํŠธ ๊นจ์ง** โ€” Service/Facade ์ƒ์„ฑ์ž์— ์˜์กด์„ฑ ์ถ”๊ฐ€ | ์ค‘ | ๊ฐ Task์—์„œ ๊ธฐ์กด ๋‹จ์œ„ํ…Œ์ŠคํŠธ Mock ์ฃผ์ž… ๋ฐ˜๋“œ์‹œ ์—…๋ฐ์ดํŠธ | +| 7 | **Redis ์žฅ์•  ์‹œ ์„œ๋น„์Šค ์ค‘๋‹จ** | ์ค‘ | CacheManager ์ „ ๋ฉ”์„œ๋“œ try-catch + Read Model ์ธ๋ฑ์Šค๊ฐ€ ์ตœ์ข… ์•ˆ์ „๋ง | +| 8 | **DDL ๋ฏธ๋ฐ˜์˜** | ์ค‘ | DDL migration ์Šคํฌ๋ฆฝํŠธ ๋ณ„๋„ ์ œ๊ณต | +| 9 | **Read Model ์ €์žฅ์†Œ ์ฆ๊ฐ€** โ€” products ๋ฐ์ดํ„ฐ ์ค‘๋ณต ์ €์žฅ | ํ•˜ | ์ƒํ’ˆ ๋ฐ์ดํ„ฐ ํฌ๊ธฐ ์ž์ฒด๊ฐ€ ์ž‘์Œ. 10๋งŒ๊ฑด ๊ธฐ์ค€ ์ˆ˜์‹ญ MB ์ˆ˜์ค€ | +| 10 | **single-key ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ** โ€” ์ธ๊ธฐ ์ƒํ’ˆ ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ ๋™์‹œ DB ์กฐํšŒ | ์ค‘ | LocalCacheLock(double-check) + PER(๋งŒ๋ฃŒ ์˜ˆ๋ฐฉ) 3๊ณ„์ธต ๋ฐฉ์–ด | +| 11 | **PER ๋น„๋™๊ธฐ ๊ฐฑ์‹  ์‹คํŒจ** โ€” CompletableFuture ๋‚ด ์˜ˆ์™ธ | ํ•˜ | ๊ฐฑ์‹  ์‹คํŒจํ•ด๋„ ๊ธฐ์กด ์บ์‹œ ๊ฐ’ ์ •์ƒ ๋ฐ˜ํ™˜. ๋‹ค์Œ PER ๋˜๋Š” TTL ๋งŒ๋ฃŒ ์‹œ ์žฌ์‹œ๋„ | + +--- + +## ์„ฑ๋Šฅ ์ธก์ • ๊ณ„ํš + +### ์ธก์ • ์ถ• + +| ์ถ• | ๊ฐ’ | ์„ค๋ช… | +|---|---|---| +| **๋ฐ์ดํ„ฐ ๊ทœ๋ชจ** | 10๋งŒ / 100๋งŒ / 1000๋งŒ | ์ธ๋ฑ์Šค ํšจ๊ณผ๊ฐ€ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์— ๋”ฐ๋ผ ์–ด๋–ป๊ฒŒ ๋ณ€ํ•˜๋Š”์ง€ | +| **ํŠธ๋ž˜ํ”ฝ ์œ ํ˜•** | ๋‹จ์ผ ์ฟผ๋ฆฌ / ๋ฒ„์ŠคํŠธ / ์ง€์† ๋ถ€ํ•˜ | ๋™์‹œ์„ฑ์— ๋”ฐ๋ฅธ ์„ฑ๋Šฅ ๋ณ€ํ™” | +| **์ธก์ • ๋ ˆ๋ฒจ** | DB ์ฟผ๋ฆฌ / API | ์ธ๋ฑ์Šค ํšจ๊ณผ vs ์บ์‹œ ํšจ๊ณผ ๋ถ„๋ฆฌ ์ธก์ • | + +### ์ธก์ • ๋งคํŠธ๋ฆญ์Šค + +| | 10๋งŒ | 100๋งŒ | 1000๋งŒ | +|---|:---:|:---:|:---:| +| **๋‹จ์ผ ์ฟผ๋ฆฌ** (EXPLAIN + latency) | O | O | O | +| **๋ฒ„์ŠคํŠธ** (N concurrent) | O | O | O | +| **์ง€์† ๋ถ€ํ•˜** (N RPS ร— T์ดˆ) | O | O | O | + +### ์ธก์ • ๋ ˆ๋ฒจ๋ณ„ ๋ชฉ์  + +| ๋ ˆ๋ฒจ | ๋„๊ตฌ | ์ธก์ • ๋Œ€์ƒ | ์ธก์ • ์‹œ์  | +|------|------|-----------|-----------| +| **DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ** | `@SpringBootTest` + `DataSource` + `ExecutorService` | EXPLAIN ๊ฒฐ๊ณผ, ์ˆœ์ˆ˜ ์ฟผ๋ฆฌ latency, ์ธ๋ฑ์Šค ํšจ๊ณผ | AS-IS โ†’ TO-BE (์ธ๋ฑ์Šค ์ ์šฉ ํ›„) | +| **API ๋ ˆ๋ฒจ** | `@SpringBootTest` + `MockMvc` + `ExecutorService` | ์ „์ฒด ์Šคํƒ latency, ์บ์‹œ ํžˆํŠธ/๋ฏธ์Šค ํšจ๊ณผ, ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ํšจ๊ณผ | AS-IS (๊ธฐ์ค€์„ ) โ†’ TO-BE (์บ์‹œ ์ ์šฉ ํ›„) | + +### ๋ณ‘๋ ฌ ์‹คํ–‰ + +DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ๊ณผ API ๋ ˆ๋ฒจ ํ…Œ์ŠคํŠธ๋ฅผ ๋ณ„๋„ JVM fork์—์„œ ๋™์‹œ ์‹คํ–‰ํ•˜์—ฌ ์ธก์ • ์‹œ๊ฐ„์„ ๋‹จ์ถ•ํ•œ๋‹ค. + +```bash +# ๋ณ‘๋ ฌ ์‹คํ–‰ (DB + API ๋™์‹œ, maxParallelForks=2) +./gradlew :apps:commerce-api:benchmarkTest --tests "*PerformanceTest*" -PtestMaxParallelForks=2 +``` + +- `build.gradle.kts`์— `testMaxParallelForks` ํ”„๋กœํผํ‹ฐ ์˜ค๋ฒ„๋ผ์ด๋“œ ์ง€์› (๊ธฐ๋ณธ๊ฐ’=1) +- ๊ฐ fork๋Š” ๋…๋ฆฝ๋œ TestContainers(MySQL + Redis)๋ฅผ ๊ธฐ๋™ํ•˜๋ฏ€๋กœ ๊ฒฉ๋ฆฌ ๋ณด์žฅ + +### ์ธก์ • Phase + +``` +Phase 1: AS-IS (ํ˜„์žฌ โ€” ์ธ๋ฑ์Šค/์บ์‹œ ์—†์Œ) โœ… ์™„๋ฃŒ + โ”œโ”€ DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ: 6 UC ร— 3 ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ร— 3 ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + โ””โ”€ API ๋ ˆ๋ฒจ: ๋ชฉ๋ก 6 UC + ์ƒ์„ธ ร— 3 ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ร— 3 ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + +Phase 2: TO-BE ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค) + โ””โ”€ DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ: 6 UC ร— 3 ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ร— 3 ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + +Phase 3: TO-BE ์บ์‹œ ์ ์šฉ ํ›„ + โ””โ”€ API ๋ ˆ๋ฒจ: ์ƒ์„ธ/๋ชฉ๋ก API ร— 3 ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ร— 3 ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + + ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ์‹œ๋‚˜๋ฆฌ์˜ค (single-key / multi-key) +``` + +### ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• ํŒŒ๋ผ๋ฏธํ„ฐ + +| ์œ ํ˜• | ํŒŒ๋ผ๋ฏธํ„ฐ | ์ธก์ • ๋ฐฉ๋ฒ• | +|------|---------|-----------| +| **๋‹จ์ผ ์ฟผ๋ฆฌ** | 1 thread, 5ํšŒ ๋ฐ˜๋ณต | warmup 3ํšŒ + ์ธก์ • 5ํšŒ ํ‰๊ท /min/max | +| **๋ฒ„์ŠคํŠธ** | 100 concurrent threads, `CountDownLatch` ์ผ์ œ ์‹œ์ž‘ | p50/p95/p99 ์‘๋‹ต์‹œ๊ฐ„, ์—๋Ÿฌ์œจ | +| **์ง€์† ๋ถ€ํ•˜** | 20 RPS ร— 10์ดˆ (์ด 200 ์š”์ฒญ) | ํ‰๊ท /p50/p95/p99 ์‘๋‹ต์‹œ๊ฐ„, ์‹ค์ œ QPS | + +**๋ชฉํ‘œ RPS ์„ค์ • ๊ทผ๊ฑฐ**: ์ดˆ๊ธฐ 50 RPS ร— 30์ดˆ์—์„œ TestContainers ํ™˜๊ฒฝ์˜ ์ปค๋„ฅ์…˜ ํ’€ ๊ณ ๊ฐˆ์œผ๋กœ ์ธก์ •์ด ๋ถˆ์•ˆ์ •ํ•ด์ ธ, ์•ˆ์ •์  ์ƒ๋Œ€ ๋น„๊ต๊ฐ€ ๊ฐ€๋Šฅํ•œ 20 RPS ร— 10์ดˆ๋กœ ์กฐ์ •. 20 RPS๋Š” ํ”ผํฌ ๋ฐฐ์ˆ˜ 3x ยท ํ”ผํฌ 4์‹œ๊ฐ„ ยท ์‚ฌ์šฉ์ž๋‹น 10~15ํšŒ ํ˜ธ์ถœ ๊ธฐ์ค€์œผ๋กœ **DAU 5~8๋งŒ ๊ทœ๋ชจ** ํŠธ๋ž˜ํ”ฝ์— ํ•ด๋‹นํ•œ๋‹ค. ์ƒ์„ธ ์—ญ์‚ฐ์€ `03-as-is-performance-measurement.md` ์ฐธ๊ณ . + +### ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ์ธก์ • (Phase 3 โ€” API ๋ ˆ๋ฒจ) + +| ์‹œ๋‚˜๋ฆฌ์˜ค | ์„ค์ • | ์ธก์ • ์ง€ํ‘œ | +|---------|------|-----------| +| **single-key ์Šคํƒฌํ”ผ๋“œ** | ์ธ๊ธฐ ์ƒํ’ˆ 1๊ฐœ ์บ์‹œ ๋งŒ๋ฃŒ โ†’ 100 concurrent ์š”์ฒญ | DB ์ฟผ๋ฆฌ ํšŸ์ˆ˜ (๋ชฉํ‘œ: 1ํšŒ), p99 ์‘๋‹ต์‹œ๊ฐ„ | +| **multi-key ์Šคํƒฌํ”ผ๋“œ** | 100๊ฐœ ์บ์‹œ ๋™์‹œ ๋งŒ๋ฃŒ โ†’ ๊ฐ ํ‚ค์— ์š”์ฒญ | jitter ๋ถ„์‚ฐ ํšจ๊ณผ, DB ์ˆœ๊ฐ„ ๋ถ€ํ•˜ | +| **PER ํšจ๊ณผ** | TTL ์ž„๋ฐ• ์ƒํƒœ์—์„œ ์ง€์† ๋ถ€ํ•˜ | ์บ์‹œ ๋งŒ๋ฃŒ ๋ฐœ์ƒ ํšŸ์ˆ˜ (PER ๋ฏธ์ ์šฉ vs ์ ์šฉ) | +| **Redis ์žฅ์• ** | Redis ์—ฐ๊ฒฐ ์ฐจ๋‹จ ์ƒํƒœ์—์„œ ์š”์ฒญ | ์„œ๋น„์Šค ์ •์ƒ ๋™์ž‘ ์—ฌ๋ถ€, DB fallback ์‘๋‹ต์‹œ๊ฐ„ | + +### ์ธก์ • ๊ฒฐ๊ณผ ์ €์žฅ ๋ฐ ์‹œ๊ฐํ™” + +| ํŒŒ์ผ | ๋‚ด์šฉ | +|------|------| +| `round5-docs/03-as-is-performance-measurement.md` | AS-IS ์ธก์ • ๊ฒฐ๊ณผ (DB ์ฟผ๋ฆฌ + API ๋ ˆ๋ฒจ, ์ „ ๊ทœ๋ชจ ์™„๋ฃŒ) | +| `round5-docs/04-to-be-index-measurement.md` | TO-BE ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ ์ธก์ • ๊ฒฐ๊ณผ | +| `round5-docs/05-to-be-cache-measurement.md` | TO-BE ์บ์‹œ ์ ์šฉ ํ›„ ์ธก์ • ๊ฒฐ๊ณผ + ์Šคํƒฌํ”ผ๋“œ ํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ | + +๊ฐ md ํŒŒ์ผ์— ๊ตฌ์กฐํ™”๋œ ํ…Œ์ด๋ธ”๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•œ๋‹ค. + +#### ์‹œ๊ฐํ™” (Chart.js HTML) + +๊ฐ Phase ์ธก์ • ๊ฒฐ๊ณผ๋ฅผ Chart.js ๊ธฐ๋ฐ˜ HTML๋กœ ์‹œ๊ฐํ™”ํ•œ๋‹ค. ๋ธŒ๋ผ์šฐ์ €์—์„œ ์—ด์–ด ์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒํ•˜๊ฒŒ ํ™•์ธ ๊ฐ€๋Šฅ. + +| ํŒŒ์ผ | Phase | ๋‚ด์šฉ | +|------|:-----:|------| +| `round5-docs/as-is-performance-visualization.html` | Phase 1 | AS-IS ์ธก์ • ๊ฒฐ๊ณผ ์‹œ๊ฐํ™” | +| `round5-docs/to-be-index-visualization.html` | Phase 2 | TO-BE ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ ์‹œ๊ฐํ™” | +| `round5-docs/to-be-cache-visualization.html` | Phase 3 | TO-BE ์บ์‹œ ์ ์šฉ ํ›„ ์‹œ๊ฐํ™” | +| `round5-docs/06-performance-comparison.html` | ์ „์ฒด | AS-IS vs TO-BE ์ „์ฒด ๋น„๊ต ๊ทธ๋ž˜ํ”„ | + +**์‹œ๊ฐํ™” ๊ตฌ์„ฑ ์›์น™**: + +1. **์ƒ๋‹จ UC ๋ ˆํผ๋Ÿฐ์Šค**: ์ฟผ๋ฆฌ ์œ ํ˜•๋ณ„ WHERE/ORDER BY ์กฐ๊ฑด + ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• ์ •์˜ ํ…Œ์ด๋ธ”์„ ์ƒ๋‹จ์— ๋ฐฐ์น˜ํ•˜์—ฌ, ์ฐจํŠธ ๋ผ๋ฒจ๋งŒ ๋ณด๊ณ ๋„ ์‹ค์ œ ์ฟผ๋ฆฌ๋ฅผ ํŒŒ์•… ๊ฐ€๋Šฅ +2. **์ƒ๋‹จ KPI ์นด๋“œ**: ํ•ต์‹ฌ ์ˆ˜์น˜(๋‹จ์ผ ์ฟผ๋ฆฌ ์‘๋‹ต์‹œ๊ฐ„, ์—๋Ÿฌ์œจ, QPS ๋“ฑ)๋ฅผ 6๊ฐœ ์ด๋‚ด ์นด๋“œ๋กœ ์š”์•ฝ +3. **๋น„๊ต ๊ด€์ (์ง€ํ‘œ) ์ค‘์‹ฌ ์„น์…˜ ๊ตฌ์„ฑ**: ์‹คํ—˜๋ณ„(A-1, A-2, B-1, ...)์ด ์•„๋‹Œ ๋น„๊ต ์ง€ํ‘œ๋ณ„๋กœ ์„น์…˜์„ ๋‚˜๋ˆ  ํ•œ๋ˆˆ์— ๋น„๊ต + - ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต (DB vs API ์ขŒ์šฐ ๋ฐฐ์น˜) + - ์—๋Ÿฌ์œจ ๋น„๊ต (๋™์ผ Y์ถ• 0~100%) + - ์ฒ˜๋ฆฌ๋Ÿ‰ ๋น„๊ต (๋™์ผ Y์ถ• + ๋ชฉํ‘œ์„ ) + - DB vs API ์˜ค๋ฒ„ํ—ค๋“œ ๋น„๊ต +4. **์ฐจํŠธ ๋ผ๋ฒจ**: UC ์ฝ”๋“œ(UC1, UC3) ๋Œ€์‹  ์‹ค์ œ ์ฟผ๋ฆฌ ์กฐ๊ฑด ๋ช…์‹œ (์˜ˆ: `์ „์ฒด+์ตœ์‹ ์ˆœ`, `๋ธŒ๋žœ๋“œ+์ธ๊ธฐ์ˆœ`) +5. **0 ๊ฐ’ ํ‘œ์‹œ**: ๊ฐ’์ด 0์ธ ๋ฐ” ์œ„์— "0" ๋ผ๋ฒจ์„ ํ‘œ์‹œํ•˜์—ฌ ์ธก์ • ๋ˆ„๋ฝ์ด ์•„๋‹˜์„ ๋ช…์‹œ (Chart.js custom plugin) +6. **๋น„๊ต ์ฐจํŠธ์˜ ์ถ• ํ†ต์ผ**: ์—๋Ÿฌ์œจ์€ 0~100%, QPS๋Š” 0~max+์—ฌ์œ  ๋“ฑ ๋™์ผ ์ง€ํ‘œ์˜ ์ฐจํŠธ๋Š” Y์ถ• ๋ฒ”์œ„๋ฅผ ํ†ต์ผํ•˜์—ฌ ์ง์ ‘ ๋น„๊ต ๊ฐ€๋Šฅ + +**์ฐจํŠธ ์œ ํ˜•**: +- ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„ ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต (bar chart) +- AS-IS vs TO-BE ๋น„๊ต (grouped bar chart) +- ์บ์‹œ ํžˆํŠธ/๋ฏธ์Šค ๋น„๊ต (line chart) +- ๋™์‹œ ์š”์ฒญ ์ˆ˜๋ณ„ p50/p95/p99 ๋ถ„ํฌ (line chart) +- ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ์ „/ํ›„ DB ์ฟผ๋ฆฌ ํšŸ์ˆ˜ ๋น„๊ต (bar chart) + +### ํ…Œ์ŠคํŠธ ํŒŒ์ผ ๊ตฌ์กฐ + +| ํŒŒ์ผ | ๋ ˆ๋ฒจ | Phase | +|------|------|-------| +| `ProductIndexPerformanceTest.java` | DB ์ฟผ๋ฆฌ | Phase 1 (AS-IS) + Phase 2 (TO-BE ์ธ๋ฑ์Šค) | +| `ProductApiPerformanceTest.java` | API | Phase 1 (AS-IS ๊ธฐ์ค€์„ ) + Phase 3 (TO-BE ์บ์‹œ) | +| `CacheStampedeTest.java` | API | Phase 3 (์Šคํƒฌํ”ผ๋“œ ์‹œ๋‚˜๋ฆฌ์˜ค) | + +--- + +## ๊ฒ€์ฆ ๋ฐฉ๋ฒ• + +```bash +# 1. EXPLAIN ์ „ํ›„ ๋น„๊ต (AS-IS vs TO-BE) +./gradlew :apps:commerce-api:benchmarkTest --tests "*ProductIndexPerformanceTest" + +# 2. ์บ์‹œ ์ธํ”„๋ผ + ์Šคํƒฌํ”ผ๋“œ ํ…Œ์ŠคํŠธ +./gradlew :apps:commerce-api:test --tests "*ProductCacheManagerTest" +./gradlew :apps:commerce-api:test --tests "*LocalCacheLockTest" +./gradlew :apps:commerce-api:test --tests "*CacheStampedeTest" + +# 3. ๋ชฉ๋ก ์บ์‹œ + ์ƒ์„ธ ์บ์‹œ +./gradlew :apps:commerce-api:test --tests "*ProductQueryServiceTest" +./gradlew :apps:commerce-api:test --tests "*ProductQueryFacadeTest" + +# 4. ์บ์‹œ ๋ฌดํšจํ™” + Read Model ๋™๊ธฐํ™” +./gradlew :apps:commerce-api:test --tests "*ProductCommandServiceTest" +./gradlew :apps:commerce-api:test --tests "*ProductCommandFacadeTest" +./gradlew :apps:commerce-api:test --tests "*BrandCommandFacadeTest" + +# 5. E2E ํ…Œ์ŠคํŠธ +./gradlew :apps:commerce-api:test --tests "*ProductControllerE2ETest" + +# 6. ์„ฑ๋Šฅ ์ธก์ • (API ๋ ˆ๋ฒจ) +./gradlew :apps:commerce-api:benchmarkTest --tests "*ProductApiPerformanceTest" + +# 7. ์ „์ฒด ๋นŒ๋“œ (ArchUnit ํฌํ•จ) +./gradlew :apps:commerce-api:test +``` diff --git a/round5-docs/03-as-is-performance-measurement.md b/round5-docs/03-as-is-performance-measurement.md new file mode 100644 index 000000000..2bc345349 --- /dev/null +++ b/round5-docs/03-as-is-performance-measurement.md @@ -0,0 +1,403 @@ +# AS-IS ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ + +## ์ธก์ • ํ™˜๊ฒฝ + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| DB | MySQL 8.0 (TestContainers) | +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | 10๋งŒ / 100๋งŒ / 1000๋งŒ | +| ๋ธŒ๋žœ๋“œ | 50๊ฐœ (๊ท ๋“ฑ ๋ถ„ํฌ) | +| ์ƒํ’ˆ ์ƒํƒœ | ์ „๋ถ€ ํ™œ์„ฑ (deleted_at IS NULL) | +| ์ธ๋ฑ์Šค | PK๋งŒ ์กด์žฌ (products.id, brands.id) | +| ์ฟผ๋ฆฌ ํŒจํ„ด | `products LEFT JOIN brands` | +| Connection Pool | HikariCP (๊ธฐ๋ณธ 10๊ฐœ) | + +## ์ธก์ • ๋ ˆ๋ฒจ + +| ๋ ˆ๋ฒจ | ์ธก์ • ๋Œ€์ƒ | ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค | ๋น„๊ต ๋ชฉ์  | +|------|----------|-------------|----------| +| **DB ์ฟผ๋ฆฌ** | ์ˆœ์ˆ˜ SQL ์‹คํ–‰ (JDBC ์ง์ ‘ ํ˜ธ์ถœ) | `ProductIndexPerformanceTest` | ์ธ๋ฑ์Šค ํšจ๊ณผ ๋น„๊ต | +| **API** | Controller โ†’ Facade โ†’ Service โ†’ Repository โ†’ DB (MockMvc) | `ProductApiPerformanceTest` | ์บ์‹œ ํšจ๊ณผ ๋น„๊ต | + +## ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + +| ์œ ํ˜• | ํŒŒ๋ผ๋ฏธํ„ฐ | ์„ค๋ช… | +|------|---------|------| +| **๋‹จ์ผ ์ฟผ๋ฆฌ** | 1 thread, warmup 3ํšŒ + ์ธก์ • 5ํšŒ | EXPLAIN + ์ˆœ์ˆ˜ ์ฟผ๋ฆฌ/API ์‹คํ–‰์‹œ๊ฐ„ | +| **๋ฒ„์ŠคํŠธ** | 100 concurrent threads, CountDownLatch ๋™์‹œ ์‹œ์ž‘ | ๋™์‹œ ์š”์ฒญ ํญ์ฃผ ์‹œ๋‚˜๋ฆฌ์˜ค | +| **์ง€์† ๋ถ€ํ•˜** | 20 RPS ร— 10์ดˆ = 200 ์š”์ฒญ | ์ผ์ • ํŠธ๋ž˜ํ”ฝ ์œ ์ง€ ์‹œ๋‚˜๋ฆฌ์˜ค | + +### ๋ชฉํ‘œ RPS ์„ค์ • ๊ทผ๊ฑฐ + +**20 RPS๋กœ ์„ค์ •ํ•œ ์ด์œ :** + +์ดˆ๊ธฐ ์„ค๊ณ„ ์‹œ 50 RPS ร— 30์ดˆ๋กœ ์„ค์ •ํ–ˆ์œผ๋‚˜, TestContainers ํ™˜๊ฒฝ(Docker ์ปจํ…Œ์ด๋„ˆ ๊ธฐ๋ฐ˜ MySQL, HikariCP ๊ธฐ๋ณธ 10๊ฐœ)์—์„œ 100๋งŒ๊ฑด ์ด์ƒ ๊ทœ๋ชจ ํ…Œ์ŠคํŠธ ์‹œ ์ปค๋„ฅ์…˜ ํ’€ ๊ณ ๊ฐˆ๊ณผ ํƒ€์ž„์•„์›ƒ์ด ๊ณผ๋„ํ•˜๊ฒŒ ๋ฐœ์ƒํ•˜์—ฌ **์ธก์ • ์ž์ฒด๊ฐ€ ๋ถˆ์•ˆ์ •**ํ•ด์กŒ๋‹ค. AS-IS vs TO-BE **์ƒ๋Œ€ ๋น„๊ต**๊ฐ€ ํ•ต์‹ฌ ๋ชฉ์ ์ด๋ฏ€๋กœ, ๋™์ผ ์กฐ๊ฑด์—์„œ ์•ˆ์ •์ ์œผ๋กœ ๊ฒฐ๊ณผ๋ฅผ ์ˆ˜์ง‘ํ•  ์ˆ˜ ์žˆ๋Š” 20 RPS ร— 10์ดˆ๋กœ ์กฐ์ •ํ–ˆ๋‹ค. + +**20 RPS๊ฐ€ ์ปค๋ฒ„ํ•˜๋Š” ํŠธ๋ž˜ํ”ฝ ๊ทœ๋ชจ (์—ญ์‚ฐ):** + +| ๊ฐ€์ • | ๊ฐ’ | ๊ทผ๊ฑฐ | +|------|---|------| +| ํ”ผํฌ RPS | 20 req/s | ์ธก์ • ๋ชฉํ‘œ์น˜ | +| ํ”ผํฌ ์‹œ๊ฐ„๋Œ€ | 4์‹œ๊ฐ„/์ผ | ์ด์ปค๋จธ์Šค ํ”ผํฌ: ์ ์‹ฌ 12~14์‹œ, ์ €๋… 20~22์‹œ | +| ํ”ผํฌ ๋ฐฐ์ˆ˜ | 3๋ฐฐ | ์ผ๋ฐ˜์  ์›น์„œ๋น„์Šค ํ”ผํฌ ๋Œ€ ํ‰๊ท  ๋น„์œจ | +| ๋น„ํ”ผํฌ RPS | ~6.7 req/s | 20 / 3 | +| **์ผ์ผ ์ด ์š”์ฒญ** | **~77๋งŒ ๊ฑด** | ํ”ผํฌ 288K + ๋น„ํ”ผํฌ 482K | +| ์‚ฌ์šฉ์ž๋‹น API ํ˜ธ์ถœ | 10~15ํšŒ/์„ธ์…˜ | ๋ชฉ๋ก ์กฐํšŒ 5~10ํšŒ + ์ƒ์„ธ ์กฐํšŒ 3~5ํšŒ | +| **์˜ˆ์ƒ DAU** | **์•ฝ 5~8๋งŒ** | 77๋งŒ / 10~15 | + +์ฆ‰, 20 RPS๋ฅผ ์•ˆ์ •์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค๋ฉด **ํ”ผํฌ ์‹œ๊ฐ„๋Œ€ ๊ธฐ์ค€ DAU 5~8๋งŒ ๊ทœ๋ชจ**์˜ ์ด์ปค๋จธ์Šค ์ƒํ’ˆ ์กฐํšŒ ํŠธ๋ž˜ํ”ฝ์„ ๊ฐ๋‹นํ•  ์ˆ˜ ์žˆ๋‹ค. ๋‹จ, AS-IS ์ƒํƒœ์—์„œ๋Š” 100๋งŒ๊ฑด ์ด์ƒ์—์„œ 20 RPS์กฐ์ฐจ ๋‹ฌ์„ฑํ•˜์ง€ ๋ชปํ•˜๋ฏ€๋กœ(์‹ค์ œ QPS 0.3~5.9), ์ธ๋ฑ์Šค์™€ ์บ์‹œ ์ ์šฉ์ด ํ•„์ˆ˜์ ์ด๋‹ค. + +## ๋ณ‘๋ ฌ ์‹คํ–‰ + +DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ๊ณผ API ๋ ˆ๋ฒจ ํ…Œ์ŠคํŠธ๋Š” ๋ณ„๋„ JVM fork์—์„œ ๋ณ‘๋ ฌ ์‹คํ–‰ํ•˜์—ฌ ์ธก์ • ์‹œ๊ฐ„์„ ๋‹จ์ถ•ํ•œ๋‹ค. + +```bash +# ๊ธฐ๋ณธ ์‹คํ–‰ (์ˆœ์ฐจ, maxParallelForks=1) +./gradlew :apps:commerce-api:benchmarkTest --tests "*PerformanceTest*" + +# ๋ณ‘๋ ฌ ์‹คํ–‰ (DB + API ๋™์‹œ, maxParallelForks=2) +./gradlew :apps:commerce-api:benchmarkTest --tests "*PerformanceTest*" -PtestMaxParallelForks=2 +``` + +- ๊ฐ fork๋Š” ๋…๋ฆฝ๋œ TestContainers(MySQL + Redis)๋ฅผ ๊ธฐ๋™ํ•˜๋ฏ€๋กœ ๊ฒฉ๋ฆฌ ๋ณด์žฅ +- `maxParallelForks=2`๋กœ ์ œํ•œ: 6๊ฐœ ์ „์ฒด ๋ณ‘๋ ฌ์€ ๋ฆฌ์†Œ์Šค ๊ฒฝํ•ฉ์œผ๋กœ ์ธก์ • ์‹ ๋ขฐ๋„ ์ €ํ•˜ + +## ๋ฐ์ดํ„ฐ ๋ถ„ํฌ + +| ํ•ญ๋ชฉ | 10๋งŒ๊ฑด | 100๋งŒ๊ฑด | 1000๋งŒ๊ฑด | +|------|:---:|:---:|:---:| +| ์ „์ฒด/ํ™œ์„ฑ ์ƒํ’ˆ | 100,000 | 1,000,000 | 10,000,000 | +| ๋ธŒ๋žœ๋“œ๋‹น ์ƒํ’ˆ ์ˆ˜ | ~2,000 | ~20,000 | ~200,000 | +| ๊ฐ€๊ฒฉ ๋ฒ”์œ„ | 1,000 ~ 100,000 | 1,000 ~ 100,000 | 1,000 ~ 100,000 | +| ์ข‹์•„์š” ๋ฒ”์œ„ | 0 ~ 10,000 | 0 ~ 10,000 | 0 ~ 10,000 | +| ์‚ฝ์ž… ์‹œ๊ฐ„ (ms) | 3,228 | 26,129 | 258,582 | + +๊ฐ ์ƒํ’ˆ์€ `brand_id`(random 1~50), `price`(random 1K~100K), `like_count`(random 0~10K), `created_at`(random 0~365์ผ ์ „)์ด ๋ชจ๋‘ ๋žœ๋คํ•˜๊ฒŒ ์ƒ์„ฑ๋œ๋‹ค. ๋™์ผํ•œ ๊ฐ’์˜ ํ–‰์ด ์—†์œผ๋ฏ€๋กœ ์ •๋ ฌยทํ•„ํ„ฐ๋ง ๋น„์šฉ์ด ์‹ค์„œ๋น„์Šค์— ๊ฐ€๊น๋‹ค. + +## ํ˜„์žฌ ์ธ๋ฑ์Šค + +``` +[products] + Key_name Column_name Non_unique + PRIMARY id 0 +``` + +products ํ…Œ์ด๋ธ”์— PK ์™ธ ์ธ๋ฑ์Šค๊ฐ€ ์—†๋‹ค. ๋ชจ๋“  ์กฐํšŒ๊ฐ€ Full Table Scan. + +--- + +# A. DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ + +## A-1. ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • (EXPLAIN + ์‹คํ–‰์‹œ๊ฐ„) + +### ๊ณตํ†ต EXPLAIN ๊ฒฐ๊ณผ + +๋ชจ๋“  ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์—์„œ ๋™์ผ: +- `products` ํ…Œ์ด๋ธ”: **type=ALL** (Full Table Scan), **key=null**, Extra=**Using where; Using filesort** +- `brands` ํ…Œ์ด๋ธ”: brandId ์—†์œผ๋ฉด `eq_ref`, ์žˆ์œผ๋ฉด `const` (PK lookup) + +### ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ฟผ๋ฆฌ (SELECT + LEFT JOIN + ORDER BY + LIMIT 20) + +```sql +-- UC1/2/4/5 ๊ณตํ†ต ํŒจํ„ด (LATEST, PRICE_ASC) +SELECT p.id, p.brand_id, b.name, p.name, p.price, p.stock +FROM products p LEFT JOIN brands b ON b.id = p.brand_id +WHERE p.deleted_at IS NULL [AND p.brand_id = ?] +ORDER BY {sort_column} +LIMIT 20 + +-- UC3/6 LIKES_DESC ํŒจํ„ด (๋น„์ •๊ทœํ™” ์ „ โ€” LEFT JOIN likes + GROUP BY + COUNT) +SELECT p.id, p.brand_id, b.name AS brand_name, p.name, p.price, p.stock, COUNT(l.id) AS like_count +FROM products p LEFT JOIN brands b ON b.id = p.brand_id +LEFT JOIN likes l ON l.target_type = 'PRODUCT' AND l.target_id = p.id +WHERE p.deleted_at IS NULL [AND p.brand_id = ?] +GROUP BY p.id +ORDER BY like_count DESC +LIMIT 20 +``` + +| UC | ์กฐ๊ฑด | ์ •๋ ฌ | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|----|------|------|:---:|:---:|:---:|:---:|:---:| +| 1 | brandId=X | LATEST | **27.68** | **585.45** | **3,897.22** | 21.1 | 6.7 | +| 2 | brandId=X | PRICE_ASC | **33.44** | **560.41** | **4,184.09** | 16.8 | 7.5 | +| 3 | brandId=X | LIKES_DESC | **25.69** | **526.67** | **3,614.20** | 20.5 | 6.9 | +| 4 | brandId=1 | LATEST | **21.88** | **422.82** | **3,782.83** | 19.3 | 8.9 | +| 5 | brandId=1 | PRICE_ASC | **22.11** | **408.43** | **3,489.15** | 18.5 | 8.5 | +| 6 | brandId=1 | LIKES_DESC | **20.80** | **429.17** | **3,961.33** | 20.6 | 9.2 | + +### COUNT ์ฟผ๋ฆฌ + +```sql +SELECT COUNT(*) FROM products WHERE deleted_at IS NULL [AND brand_id = ?] +``` + +| ์กฐ๊ฑด | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|------|:---:|:---:|:---:|:---:|:---:| +| brandId=X (์ „์ฒด) | **10.59** | **279.32** | **2,147.34** | 26.4 | 7.7 | +| brandId=1 | **11.88** | **314.32** | **2,323.93** | 26.5 | 7.4 | + +--- + +## A-2. ๋ฒ„์ŠคํŠธ ์ธก์ • (100 concurrent) + +100๊ฐœ ์Šค๋ ˆ๋“œ๊ฐ€ CountDownLatch๋กœ ๋™์‹œ ์‹œ์ž‘. Connection Pool(10๊ฐœ) ๊ฒฝ์Ÿ ํฌํ•จ. + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 100/100 | 0 | 433 | 410 | 835 | 849 | 875 | +| UC3: brandId=X, LIKES_DESC | 100/100 | 0 | 494 | 478 | 793 | 801 | 802 | +| UC4: brandId=1, LATEST | 100/100 | 0 | 365 | 369 | 625 | 642 | 647 | + +- ์ „์ฒด ์š”์ฒญ ์„ฑ๊ณต. ์ฟผ๋ฆฌ ์ž์ฒด๊ฐ€ 20~30ms์ด๋ฏ€๋กœ 10๊ฐœ ์ปค๋„ฅ์…˜์œผ๋กœ 100๊ฑด์„ ~900ms ๋‚ด์— ์ฒ˜๋ฆฌ. +- p95๊ฐ€ 600~800ms๋Œ€: ์ปค๋„ฅ์…˜ ๋Œ€๊ธฐ ์‹œ๊ฐ„์ด ์ง€๋ฐฐ์ . + +### 100๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | **29/100** | **71** | 2,793 | 2,893 | 4,306 | 4,387 | 4,387 | +| UC3: brandId=X, LIKES_DESC | **30/100** | **70** | 2,710 | 2,782 | 4,065 | 4,078 | 4,078 | +| UC4: brandId=1, LATEST | **30/100** | **70** | 2,407 | 2,332 | 3,717 | 3,807 | 3,807 | + +- **70~71% ์š”์ฒญ ์‹คํŒจ** (HikariCP connectionTimeout) +- ์ฟผ๋ฆฌ 1๊ฑด์— 500ms+ โ†’ 10๊ฐœ ์ปค๋„ฅ์…˜์œผ๋กœ 100๊ฑด ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด ~5์ดˆ ํ•„์š” +- ์ปค๋„ฅ์…˜ ๋Œ€๊ธฐ ์ค‘ ํƒ€์ž„์•„์›ƒ ๋ฐœ์ƒ โ†’ **์„œ๋น„์Šค ์žฅ์•  ์ˆ˜์ค€** + +### 1000๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | **10/100** | **90** | 12,021 | 12,020 | 12,389 | 12,389 | 12,389 | +| UC3: brandId=X, LIKES_DESC | **10/100** | **90** | 14,824 | 14,762 | 15,146 | 15,146 | 15,146 | +| UC4: brandId=1, LATEST | **10/100** | **90** | 10,591 | 10,605 | 10,972 | 10,972 | 10,972 | + +- **90% ์š”์ฒญ ์‹คํŒจ**: ์ปค๋„ฅ์…˜ ํ’€ 10๊ฐœ๋กœ ์ฟผ๋ฆฌ๋‹น 3.5~4์ดˆ โ†’ 10๊ฑด๋งŒ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ +- ์„ฑ๊ณตํ•œ 10๊ฑด์กฐ์ฐจ avg 10~14์ดˆ: ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€ +- ์™„์ „ํ•œ ์„œ๋น„์Šค ๋ถˆ๋Šฅ ์ƒํƒœ + +--- + +## A-3. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (20 RPS ร— 10์ดˆ) + +200๊ฑด์˜ ์š”์ฒญ์„ 50ms ๊ฐ„๊ฒฉ์œผ๋กœ ์ œ์ถœ. ์‹ค์ œ ์ฒ˜๋ฆฌ๋Ÿ‰(QPS)๊ณผ ์‘๋‹ต์‹œ๊ฐ„ ๋ถ„ํฌ ์ธก์ •. + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | 30 | 28 | 39 | 45 | +| UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | 32 | 26 | 42 | 179 | +| UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | 22 | 21 | 28 | 31 | + +- 20 RPS ๋ชฉํ‘œ๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ๋‹ฌ์„ฑ. ์ฟผ๋ฆฌ๊ฐ€ 20~30ms์ด๋ฏ€๋กœ ์—ฌ์œ  ์žˆ์Œ. + +### 100๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | **110/200** | **90** | **5.8** | 3,428 | 3,910 | 4,816 | 4,972 | +| UC3: brandId=X, LIKES_DESC | **155/200** | **45** | **9.9** | 2,424 | 2,659 | 3,974 | 4,138 | +| UC4: brandId=1, LATEST | **158/200** | **42** | **9.9** | 2,446 | 2,700 | 3,952 | 3,979 | + +- **20 RPS ๋ชฉํ‘œ ๋ฏธ๋‹ฌ์„ฑ**: ์‹ค์ œ QPS 5.8~9.9 (๋ชฉํ‘œ์˜ 29~50%) +- **23~45% ์š”์ฒญ ์‹คํŒจ**: ์ปค๋„ฅ์…˜ ํƒ€์ž„์•„์›ƒ +- **20 RPS๋„ ๊ฐ๋‹น ๋ถˆ๊ฐ€** โ†’ ์ธ๋ฑ์Šค ์—†์ด๋Š” ์„œ๋น„์Šค ์šด์˜ ๋ถˆ๊ฐ€๋Šฅ + +### 1000๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | **20/200** | **180** | **0.6** | 15,273 | 13,969 | 18,156 | 18,170 | +| UC3: brandId=X, LIKES_DESC | **20/200** | **180** | **0.8** | 12,011 | 11,715 | 13,674 | 13,762 | +| UC4: brandId=1, LATEST | **20/200** | **180** | **0.8** | 12,471 | 10,874 | 16,677 | 16,918 | + +- **90% ์š”์ฒญ ์‹คํŒจ**: 200๊ฑด ์ค‘ 180๊ฑด ํƒ€์ž„์•„์›ƒ +- **์‹ค์ œ QPS 0.6~0.8**: ๋ชฉํ‘œ 20 RPS์˜ **3~4%**์— ๋ถˆ๊ณผ +- **์„œ๋น„์Šค ์™„์ „ ๋ถˆ๋Šฅ**: 20 RPS์กฐ์ฐจ ์ „ํ˜€ ๊ฐ๋‹น ๋ถˆ๊ฐ€ + +--- + +# B. API ๋ ˆ๋ฒจ + +## B-1. ๋‹จ์ผ API ์š”์ฒญ (MockMvc) + +### ๋ชฉ๋ก API (`GET /api/v1/products`) + +| UC | ์กฐ๊ฑด | ์ •๋ ฌ | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|----|------|------|:---:|:---:|:---:|:---:|:---:| +| 1 | brandId=X | LATEST | **60.18** | **516.31** | **6,174.44** | 8.6 | 12.0 | +| 2 | brandId=X | PRICE_ASC | **59.55** | **497.88** | **6,522.14** | 8.4 | 13.1 | +| 3 | brandId=X | LIKES_DESC | **51.51** | **473.23** | **6,663.48** | 9.2 | 14.1 | +| 4 | brandId=1 | LATEST | **49.52** | **502.12** | **6,643.62** | 10.1 | 13.2 | +| 5 | brandId=1 | PRICE_ASC | **48.82** | **463.31** | **9,901.90** | 9.5 | 21.4 | +| 6 | brandId=1 | LIKES_DESC | **48.07** | **482.24** | **11,604.48** | 10.0 | 24.1 | + +### ์ƒ์„ธ API (`GET /api/v1/products/{id}`) + +| UC | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|----|:---:|:---:|:---:|:---:|:---:| +| ์ƒ์„ธ: productId=1 | **4.58** | **11.41** | **18.27** | 2.5 | 1.6 | + +- ์ƒ์„ธ API๋Š” PK lookup์ด๋ฏ€๋กœ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์— ๊ฑฐ์˜ ๋น„๋ก€ํ•˜์ง€ ์•Š์Œ (O(1)) +- ๋ชฉ๋ก API ๋Œ€๋น„ **30~600๋ฐฐ** ๋น ๋ฆ„ โ†’ ์บ์‹œ ์ ์šฉ ์‹œ ํšจ๊ณผ๊ฐ€ ๊ทน๋Œ€ํ™”๋˜๋Š” ์˜์—ญ์€ **๋ชฉ๋ก API** + +--- + +## B-2. ๋ฒ„์ŠคํŠธ ์ธก์ • (100 concurrent) + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | 726 | 712 | 1,335 | 1,375 | 1,378 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 100/100 | 0 | 786 | 785 | 1,357 | 1,379 | 1,397 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | 100/100 | 0 | 795 | 763 | 1,410 | 1,436 | 1,438 | +| ์ƒ์„ธ: productId=1 | 100/100 | 0 | 72 | 71 | 127 | 127 | 129 | + +- ๋ชฉ๋ก: ์ „์ฒด ์„ฑ๊ณต์ด์ง€๋งŒ p95๊ฐ€ 1.3~1.4์ดˆ โ€” Spring ์Šคํƒ ์˜ค๋ฒ„ํ—ค๋“œ๋กœ DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ ๋Œ€๋น„ ~2๋ฐฐ +- ์ƒ์„ธ: avg 72ms โ€” PK lookup + Spring ์˜ค๋ฒ„ํ—ค๋“œ + +### 100๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | **20/100** | **80** | 2,769 | 2,161 | 3,599 | 3,686 | 3,686 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | **27/100** | **73** | 2,683 | 2,881 | 4,131 | 4,192 | 4,192 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | **29/100** | **71** | 2,738 | 2,753 | 4,253 | 4,254 | 4,254 | +| ์ƒ์„ธ: productId=1 | **100/100** | **0** | 164 | 169 | 278 | 287 | 290 | + +- ๋ชฉ๋ก: **71~80% ์š”์ฒญ ์‹คํŒจ** โ€” DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ๋ณด๋‹ค ์—๋Ÿฌ์œจ ๋†’์Œ (Spring ์Šคํƒ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ์ฆ๊ฐ€) +- ์ƒ์„ธ: **100% ์„ฑ๊ณต** โ€” PK lookup์€ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„์ด ์งง์•„ ๊ฒฝํ•ฉ ์—†์Œ + +### 1000๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | **10/100** | **90** | 37,129 | 37,139 | 37,701 | 37,701 | 37,701 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | **10/100** | **90** | 32,766 | 32,732 | 33,417 | 33,417 | 33,417 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | **10/100** | **90** | 30,445 | 30,371 | 30,781 | 30,781 | 30,781 | +| ์ƒ์„ธ: productId=1 | **100/100** | **0** | 232 | 245 | 338 | 354 | 358 | + +- ๋ชฉ๋ก: **90% ์‹คํŒจ**, ์„ฑ๊ณตํ•œ 10๊ฑด์กฐ์ฐจ avg 30~37์ดˆ โ€” ์™„์ „ํ•œ ์„œ๋น„์Šค ๋ถˆ๋Šฅ +- ์ƒ์„ธ: **100% ์„ฑ๊ณต**, avg 232ms โ€” ์ปค๋„ฅ์…˜ ๊ฒฝํ•ฉ๋งŒ ์žˆ์„ ๋ฟ ์ฟผ๋ฆฌ ์ž์ฒด๋Š” ๋น ๋ฆ„ + +--- + +## B-3. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (20 RPS ร— 10์ดˆ) + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | 58 | 53 | 74 | 122 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | 54 | 49 | 79 | 112 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | 48 | 47 | 55 | 65 | +| ์ƒ์„ธ: productId=1 | 200/200 | 0 | **20.0** | 13 | 10 | 17 | 136 | + +- 20 RPS ๋ชฉํ‘œ ๋‹ฌ์„ฑ. ๋ชฉ๋ก avg 48~58ms, ์ƒ์„ธ avg 13ms. + +### 100๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | **126/200** | **74** | **5.9** | 3,962 | 4,300 | 4,730 | 4,869 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | **116/200** | **84** | **5.3** | 4,156 | 4,419 | 5,706 | 5,834 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | **81/200** | **119** | **3.7** | 4,972 | 5,355 | 5,860 | 6,004 | +| ์ƒ์„ธ: productId=1 | **200/200** | **0** | **20.0** | 21 | 18 | 41 | 65 | + +- ๋ชฉ๋ก: **37~60% ์š”์ฒญ ์‹คํŒจ**, ์‹ค์ œ QPS 3.7~5.9 (๋ชฉํ‘œ์˜ 19~30%) +- ์ƒ์„ธ: **0% ์‹คํŒจ**, 20 RPS ์™„๋ฒฝ ๋‹ฌ์„ฑ โ€” PK lookup์ด๋ฏ€๋กœ ๋ถ€ํ•˜์™€ ๋ฌด๊ด€ + +### 1000๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| ๋ชฉ๋ก UC1: brandId=X, LATEST | **10/200** | **190** | **0.3** | 30,970 | 30,944 | 31,630 | 31,630 | +| ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | **17/200** | **183** | **0.5** | 19,227 | 21,491 | 22,250 | 22,250 | +| ๋ชฉ๋ก UC4: brandId=1, LATEST | **19/200** | **181** | **0.5** | 19,950 | 19,936 | 21,544 | 21,544 | +| ์ƒ์„ธ: productId=1 | **200/200** | **0** | **20.0** | 12 | 12 | 17 | 20 | + +- ๋ชฉ๋ก: **90~95% ์š”์ฒญ ์‹คํŒจ**, ์‹ค์ œ QPS 0.3~0.5 โ€” ์„œ๋น„์Šค ์™„์ „ ๋ถˆ๋Šฅ +- ์ƒ์„ธ: **0% ์‹คํŒจ**, 20 RPS ๋‹ฌ์„ฑ, avg 12ms โ€” ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ์•ˆ์ • + +--- + +# ๋ถ„์„ + +## ํ•ต์‹ฌ ๋ฐœ๊ฒฌ + +### 1. Full Table Scan (`type=ALL`) + filesort + +๋ชจ๋“  ์œ ์ฆˆ์ผ€์ด์Šค์—์„œ products ํ…Œ์ด๋ธ” ์ „์ฒด๋ฅผ ์Šค์บ” + ๋ฉ”๋ชจ๋ฆฌ ์ •๋ ฌ ์ˆ˜ํ–‰. + +### 2. ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์— ๋”ฐ๋ฅธ ์„ ํ˜• ์„ฑ๋Šฅ ์ €ํ•˜ + +๋‹จ์ผ ์ฟผ๋ฆฌ ๊ธฐ์ค€, ๋ฐ์ดํ„ฐ 10๋ฐฐ ์ฆ๊ฐ€ ์‹œ ์‘๋‹ต์‹œ๊ฐ„ 7~26๋ฐฐ ์ฆ๊ฐ€: + +| ๋ ˆ๋ฒจ | 10๋งŒ๊ฑด | 100๋งŒ๊ฑด | 1000๋งŒ๊ฑด | +|------|:---:|:---:|:---:| +| DB ์ฟผ๋ฆฌ (๋ชฉ๋ก) | 20~33ms | 408~585ms | 3,489~4,184ms | +| API (๋ชฉ๋ก) | 48~60ms | 463~516ms | 6,174~11,604ms | +| API (์ƒ์„ธ) | 5ms | 11ms | 18ms | + +์ธ๋ฑ์Šค ์—†์ด Full Table Scan์€ `O(N)` โ†’ ๋ฐ์ดํ„ฐ๊ฐ€ ๋Š˜๋ฉด ์„ ํ˜•์œผ๋กœ ๋А๋ ค์ง„๋‹ค. + +### 3. ๋ชฉ๋ก vs ์ƒ์„ธ โ€” ๊ทน๋ช…ํ•œ ์ฐจ์ด + +| ํ•ญ๋ชฉ | ๋ชฉ๋ก API | ์ƒ์„ธ API | +|------|:---:|:---:| +| 1000๋งŒ๊ฑด ๋‹จ์ผ ์ฟผ๋ฆฌ | 6~11์ดˆ | **18ms** | +| 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ (%) | 90 | **0** | +| 1000๋งŒ๊ฑด ์ง€์† ๋ถ€ํ•˜ QPS (๊ฑด/์ดˆ) | 0.3~0.5 | **20.0** | + +์ƒ์„ธ API๋Š” PK lookup(`O(1)`)์ด๋ฏ€๋กœ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ์•ˆ์ •์ . **์บ์‹œ ์ ์šฉ ์‹œ ROI๊ฐ€ ํฐ ์˜์—ญ์€ ๋ชฉ๋ก API.** + +### 4. ๋™์‹œ ์š”์ฒญ ์‹œ ์„œ๋น„์Šค ์žฅ์•  โ€” ๊ทœ๋ชจ๋ณ„ ์—๋Ÿฌ์œจ ๊ธ‰์ฆ + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | DB ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ (%) | API ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ (%) | API ์ง€์† ๋ถ€ํ•˜ ์—๋Ÿฌ์œจ (%) | API ์ง€์† ๋ถ€ํ•˜ QPS (๊ฑด/์ดˆ) | +|:---:|:---:|:---:|:---:|:---:| +| 10๋งŒ๊ฑด | 0 | 0 | 0 | 20.0 | +| 100๋งŒ๊ฑด | 70~71 | 71~80 | 37~60 | 3.7~5.9 | +| 1000๋งŒ๊ฑด | **90** | **90** | **90~95** | **0.3~0.5** | + +- API ๋ ˆ๋ฒจ์€ DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ๋ณด๋‹ค ์—๋Ÿฌ์œจ์ด ๋†’์Œ: Spring ์Šคํƒ(ํŠธ๋žœ์žญ์…˜, JPA, ์ง๋ ฌํ™”) ์˜ค๋ฒ„ํ—ค๋“œ๋กœ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ์ฆ๊ฐ€ +- 1000๋งŒ๊ฑด์—์„œ ๋ชฉ๋ก API์˜ ์‹ค์ œ QPS๋Š” 0.3~0.5 โ€” **20 RPS์˜ 1.5~2.5%**์— ๋ถˆ๊ณผ + +### 5. brandId ํ•„ํ„ฐ์˜ ํ•œ๊ณ„ + +brandId๊ฐ€ ์žˆ์œผ๋ฉด WHERE ์ ˆ์—์„œ ํ•„ํ„ฐ๋ง๋˜์ง€๋งŒ, **์ธ๋ฑ์Šค ์—†์ด Full Scan ํ›„ ํ•„ํ„ฐ๋ง**์ด๋ฏ€๋กœ ์„ฑ๋Šฅ ์ฐจ์ด๊ฐ€ ํฌ์ง€ ์•Š๋‹ค. + +## DB vs API ์˜ค๋ฒ„ํ—ค๋“œ + +| ๊ตฌ๊ฐ„ | 10๋งŒ๊ฑด (ms) | 100๋งŒ๊ฑด (ms) | 1000๋งŒ๊ฑด (ms) | +|------|:---:|:---:|:---:| +| DB ์ฟผ๋ฆฌ (UC1) | 28 | 585 | 3,897 | +| API (UC1) | 60 | 516 | 6,174 | +| **Spring ์Šคํƒ ์˜ค๋ฒ„ํ—ค๋“œ** | **~32** | **~-69** | **~2,277** | + +- 10๋งŒ๊ฑด: Spring ์˜ค๋ฒ„ํ—ค๋“œ(~32ms)๊ฐ€ ์ „์ฒด์˜ ~53% ์ฐจ์ง€ +- 100๋งŒ๊ฑด: ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ DB ์ฟผ๋ฆฌ ๋Œ€๋น„ ๋ฏธ๋ฏธ (์ธก์ • ์˜ค์ฐจ ๋ฒ”์œ„) +- 1000๋งŒ๊ฑด: Spring TX + JPA ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ + ์ง๋ ฌํ™” ๋น„์šฉ์ด **~2์ดˆ** ์ถ”๊ฐ€ + +## ์‹คํ–‰์‹œ๊ฐ„ ์ฐธ๊ณ ์‚ฌํ•ญ + +- TestContainers MySQL์€ ๋กœ์ปฌ Docker ์ปจํ…Œ์ด๋„ˆ. ์‹ค์ œ ์šด์˜ ํ™˜๊ฒฝ๊ณผ ์ ˆ๋Œ€๊ฐ’์€ ๋‹ค๋ฆ„. +- DB + API ํ…Œ์ŠคํŠธ๊ฐ€ ๋ณ‘๋ ฌ ์‹คํ–‰(maxParallelForks=2)๋˜๋ฏ€๋กœ, ๋ฆฌ์†Œ์Šค ๊ฒฝํ•ฉ์œผ๋กœ ์ ˆ๋Œ€๊ฐ’์ด ์ˆœ์ฐจ ์‹คํ–‰ ๋Œ€๋น„ ๋‹ค์†Œ ๋†’์„ ์ˆ˜ ์žˆ์Œ. +- **์ƒ๋Œ€์  ๋น„๊ต**(AS-IS vs TO-BE, ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„ ์ฆ๊ฐ€์œจ)๊ฐ€ ํ•ต์‹ฌ ์ง€ํ‘œ. + +--- + +## ๊ฐœ์„  ๋ฐฉํ–ฅ (TO-BE ์˜ˆ์ƒ) + +| ๊ฐœ์„  | ๊ธฐ๋Œ€ ํšจ๊ณผ | +|------|----------| +| **๋ณตํ•ฉ ์ธ๋ฑ์Šค** (`brand_id, deleted_at, sort_col` โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘) | Full Table Scan โ†’ range/ref scan, filesort ์ œ๊ฑฐ | +| **Redis ์บ์‹œ** | ๋ฐ˜๋ณต ์กฐํšŒ ์‹œ DB ์ฟผ๋ฆฌ ์ž์ฒด๋ฅผ ํšŒํ”ผ | +| **์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ** (CacheLock + PER) | ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ DB ํญ์ฃผ ๋ฐฉ์ง€ | + +TO-BE ์ธก์ •์€ ๊ฐ ๊ฐœ์„  ์ ์šฉ ํ›„ ๋™์ผ ์กฐ๊ฑด์—์„œ ์žฌ์ธก์ •ํ•˜์—ฌ ๋น„๊ตํ•œ๋‹ค: +- `04-to-be-index-measurement.md` โ€” ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ (DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ) +- `05-to-be-cache-measurement.md` โ€” ์บ์‹œ ์ ์šฉ ํ›„ (API ๋ ˆ๋ฒจ) +- `06-performance-comparison.html` โ€” Chart.js ์‹œ๊ฐํ™” (์ „์ฒด ๋น„๊ต) diff --git a/round5-docs/03-as-is-performance-visualization.html b/round5-docs/03-as-is-performance-visualization.html new file mode 100644 index 000000000..f9561ef66 --- /dev/null +++ b/round5-docs/03-as-is-performance-visualization.html @@ -0,0 +1,725 @@ + + + + + + AS-IS ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ ์‹œ๊ฐํ™” + + + + + + + +

AS-IS ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ

+

+ PK๋งŒ ์กด์žฌ (์ธ๋ฑ์Šค ์—†์Œ) · Full Table Scan + filesort · + products LEFT JOIN brands · TestContainers MySQL 8.0 +

+ + + + +
+
+
10๋งŒ๊ฑด ๋‹จ์ผ ์ฟผ๋ฆฌ
+
~25ms
+
DB ์ฟผ๋ฆฌ avg
+
+
+
100๋งŒ๊ฑด ๋‹จ์ผ ์ฟผ๋ฆฌ
+
~530ms
+
DB ์ฟผ๋ฆฌ avg (x20 ์ฆ๊ฐ€)
+
+
+
1000๋งŒ๊ฑด ๋‹จ์ผ ์ฟผ๋ฆฌ
+
~3.8s
+
DB ์ฟผ๋ฆฌ avg (x7 ์žฌ์ฆ๊ฐ€)
+
+
+
1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ
+
90%
+
100 concurrent, ๋ชฉ๋ก API
+
+
+
1000๋งŒ๊ฑด ์ง€์†๋ถ€ํ•˜ QPS
+
0.3~0.5
+
๋ชฉํ‘œ 20 RPS์˜ 1.5~2.5%
+
+
+
์ƒ์„ธ API (PK Lookup)
+
18ms
+
1000๋งŒ๊ฑด์—์„œ๋„ ์•ˆ์ •
+
+
+ + + + +

UC(Use Case) ๋ ˆํผ๋Ÿฐ์Šค

+

+ ๋ชจ๋“  ์ฐจํŠธ์—์„œ ์‚ฌ์šฉ๋˜๋Š” ์ฟผ๋ฆฌ ์œ ํ˜• ์ •์˜. ๊ณตํ†ต ํŒจํ„ด: + + SELECT ... FROM products p LEFT JOIN brands b ON b.id = p.brand_id WHERE p.deleted_at IS NULL [AND ...] ORDER BY ... LIMIT 20 + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
์ฟผ๋ฆฌ ์œ ํ˜•WHERE ์กฐ๊ฑดORDER BY์„ค๋ช…
๋ชฉ๋ก: ์ „์ฒด+์ตœ์‹ ์ˆœdeleted_at IS NULLcreated_at DESC๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์ด ์ตœ์‹ ์ˆœ ์ •๋ ฌ
๋ชฉ๋ก: ์ „์ฒด+๊ฐ€๊ฒฉ์ˆœdeleted_at IS NULLprice ASC๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์ด ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ
๋ชฉ๋ก: ์ „์ฒด+์ธ๊ธฐ์ˆœdeleted_at IS NULLlike_count DESC๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์ด ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ
๋ชฉ๋ก: ๋ธŒ๋žœ๋“œ+์ตœ์‹ ์ˆœdeleted_at IS NULL AND brand_id = 1created_at DESCํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ตœ์‹ ์ˆœ
๋ชฉ๋ก: ๋ธŒ๋žœ๋“œ+๊ฐ€๊ฒฉ์ˆœdeleted_at IS NULL AND brand_id = 1price ASCํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ
๋ชฉ๋ก: ๋ธŒ๋žœ๋“œ+์ธ๊ธฐ์ˆœdeleted_at IS NULL AND brand_id = 1like_count DESCํŠน์ • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ
COUNT: ์ „์ฒดdeleted_at IS NULL-ํŽ˜์ด์ง€๋„ค์ด์…˜์šฉ ์ „์ฒด ๊ฑด์ˆ˜ ์กฐํšŒ
COUNT: ๋ธŒ๋žœ๋“œdeleted_at IS NULL AND brand_id = 1-ํŽ˜์ด์ง€๋„ค์ด์…˜์šฉ ๋ธŒ๋žœ๋“œ๋ณ„ ๊ฑด์ˆ˜
์ƒ์„ธ (PK)id = 1-PK lookup, O(1) ์„ฑ๋Šฅ
+
+ +
+

ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• ์ •์˜

+ + + + + + + + + + + + + + + + + + + + + + + + +
ํŠธ๋ž˜ํ”ฝ ์œ ํ˜•์„ค์ •์ธก์ • ์ง€ํ‘œ๋ชฉ์ 
๋‹จ์ผ ์ฟผ๋ฆฌ/์š”์ฒญ1 thread, warmup 3ํšŒ + ์ธก์ • 5ํšŒavg ์‘๋‹ต์‹œ๊ฐ„ (ms)์ˆœ์ˆ˜ ์ฟผ๋ฆฌ/API ์„ฑ๋Šฅ ์ธก์ • (๊ฒฝํ•ฉ ์—†์Œ)
๋ฒ„์ŠคํŠธ100 concurrent threads, ๋™์‹œ ์‹œ์ž‘์—๋Ÿฌ์œจ(%), avg/p50/p95/p99/max์ˆœ๊ฐ„ ํญ์ฃผ ์‹œ๋‚˜๋ฆฌ์˜ค (์ปค๋„ฅ์…˜ ํ’€ ๊ฒฝํ•ฉ)
์ง€์† ๋ถ€ํ•˜20 RPS x 10์ดˆ = 200 ์š”์ฒญ์‹ค์ œ QPS(๊ฑด/์ดˆ), ์—๋Ÿฌ์œจ(%)์ผ์ • ํŠธ๋ž˜ํ”ฝ ์œ ์ง€ ์‹œ๋‚˜๋ฆฌ์˜ค
+
+ + + + + +

1. ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต (๋‹จ์ผ ์ฟผ๋ฆฌ/์š”์ฒญ)

+

+ ๊ฒฝํ•ฉ ์—†๋Š” ๋‹จ์ผ ์‹คํ–‰์—์„œ์˜ ์ˆœ์ˆ˜ ์„ฑ๋Šฅ.
+ ์™ผ์ชฝ: DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ (JDBC ์ง์ ‘), ์˜ค๋ฅธ์ชฝ: API ๋ ˆ๋ฒจ (MockMvc, Spring ์ „์ฒด ์Šคํƒ).
+ ๋ชจ๋“  ๋ชฉ๋ก ์ฟผ๋ฆฌ: EXPLAIN type=ALL (Full Table Scan), Using filesort. +

+ +
+
+

DB ์ฟผ๋ฆฌ โ€” ์ „์ฒด ๋ชฉ๋ก (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์Œ)

+
+
+
+

API โ€” ์ „์ฒด ๋ชฉ๋ก (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์Œ)

+
+
+
+

DB ์ฟผ๋ฆฌ โ€” ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ (brand_id = 1)

+
+
+
+

API โ€” ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ (brand_id = 1)

+
+
+
+ +
+
+

DB ์ฟผ๋ฆฌ โ€” COUNT

+
+
+
+

๋ชฉ๋ก vs ์ƒ์„ธ API ์ƒ์„ธ: PK Lookup O(1)

+
+
+
+ + + + +

2. ์—๋Ÿฌ์œจ ๋น„๊ต (๋ฒ„์ŠคํŠธ + ์ง€์† ๋ถ€ํ•˜)

+

+ ๋ชจ๋“  ์ฐจํŠธ๊ฐ€ ๋™์ผํ•œ Y์ถ•(์—๋Ÿฌ์œจ 0~100%)์„ ์‚ฌ์šฉํ•˜์—ฌ ์ง์ ‘ ๋น„๊ต ๊ฐ€๋Šฅ.
+ HikariCP ์ปค๋„ฅ์…˜ ํ’€ 10๊ฐœ. ์ฟผ๋ฆฌ๊ฐ€ ๋А๋ฆด์ˆ˜๋ก ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ์ฆ๊ฐ€ -> ๋Œ€๊ธฐ ์Šค๋ ˆ๋“œ ํƒ€์ž„์•„์›ƒ. +

+ +
+
+

DB ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ (100 concurrent) ์ปค๋„ฅ์…˜ ํƒ€์ž„์•„์›ƒ

+
+
+
+

API ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ (100 concurrent) Spring ์˜ค๋ฒ„ํ—ค๋“œ ๊ฐ€์ค‘

+
+
+
+

DB ์ง€์† ๋ถ€ํ•˜ ์—๋Ÿฌ์œจ (20 RPS x 10์ดˆ)

+
+
+
+

API ์ง€์† ๋ถ€ํ•˜ ์—๋Ÿฌ์œจ (20 RPS x 10์ดˆ)

+
+
+
+ +
+

์—๋Ÿฌ์œจ ํ•ต์‹ฌ ๋ฐœ๊ฒฌ

+
    +
  • 10๋งŒ๊ฑด: ๋ชจ๋“  ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ ์—๋Ÿฌ 0%. ๋‹จ์ผ ์ฟผ๋ฆฌ 20~30ms์ด๋ฏ€๋กœ ์ปค๋„ฅ์…˜ ํ’€ 10๊ฐœ๋กœ ์ถฉ๋ถ„.
  • +
  • 100๋งŒ๊ฑด: ๋‹จ์ผ ์ฟผ๋ฆฌ 400~580ms๋กœ ์ฆ๊ฐ€ -> ๋ฒ„์ŠคํŠธ ์‹œ 70~80% ํƒ€์ž„์•„์›ƒ, ์ง€์† ๋ถ€ํ•˜์—์„œ๋„ 37~60% ์‹คํŒจ.
  • +
  • 1000๋งŒ๊ฑด: ๋‹จ์ผ ์ฟผ๋ฆฌ 3.5~4์ดˆ -> ๋ฒ„์ŠคํŠธ 90%, ์ง€์† ๋ถ€ํ•˜ 90~95% ์‹คํŒจ. ์„œ๋น„์Šค ์™„์ „ ๋ถˆ๋Šฅ.
  • +
  • API๊ฐ€ DB๋ณด๋‹ค ์—๋Ÿฌ์œจ ๋” ๋†’์Œ: Spring TX + JPA + ์ง๋ ฌํ™” ์˜ค๋ฒ„ํ—ค๋“œ๋กœ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„์ด ๋” ๊ธธ์–ด์ง.
  • +
  • ์ƒ์„ธ API(PK lookup)๋Š” ๋ชจ๋“  ๊ทœ๋ชจ์—์„œ ์—๋Ÿฌ 0%: ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„์ด ๊ทนํžˆ ์งง์•„ ๊ฒฝํ•ฉ ์—†์Œ.
  • +
+
+ + + + +

3. ์ฒ˜๋ฆฌ๋Ÿ‰(QPS) ๋น„๊ต (์ง€์† ๋ถ€ํ•˜)

+

+ ๋ชฉํ‘œ: 20 RPS. ๋นจ๊ฐ„ ์ ์„ ์ด ๋ชฉํ‘œ์„ . ๋ชจ๋“  ์ฐจํŠธ๊ฐ€ ๋™์ผํ•œ Y์ถ•(0~22 QPS)์„ ์‚ฌ์šฉ.
+ DB ๋ ˆ๋ฒจ๊ณผ API ๋ ˆ๋ฒจ์„ ๋‚˜๋ž€ํžˆ ๋ฐฐ์น˜ํ•˜์—ฌ Spring ์Šคํƒ ์˜ค๋ฒ„ํ—ค๋“œ์— ์˜ํ•œ QPS ๊ฐ์†Œ๋ฅผ ์ง์ ‘ ๋น„๊ต. +

+ +
+
+

DB ์ฟผ๋ฆฌ โ€” ์‹ค์ œ QPS (20 RPS x 10์ดˆ)

+
+
+
+

API โ€” ์‹ค์ œ QPS (20 RPS x 10์ดˆ)

+
+
+
+ + + + +

4. DB vs API ์˜ค๋ฒ„ํ—ค๋“œ ๋น„๊ต

+

+ ๋™์ผ ์ฟผ๋ฆฌ(์ „์ฒด ๋ชฉ๋ก + ์ตœ์‹ ์ˆœ)์— ๋Œ€ํ•ด DB ์ง์ ‘ ์‹คํ–‰ vs API ์ „์ฒด ์Šคํƒ์˜ ์‘๋‹ต์‹œ๊ฐ„ ์ฐจ์ด.
+ ๊ทธ๋ฆฌ๊ณ  ๋ฒ„์ŠคํŠธ/์ง€์† ๋ถ€ํ•˜์˜ ์—๋Ÿฌ์œจ์„ DB์™€ API ๋ ˆ๋ฒจ์—์„œ ๊ทœ๋ชจ๋ณ„๋กœ ๋‚˜๋ž€ํžˆ ๋น„๊ต. +

+ +
+
+

๋‹จ์ผ ์‹คํ–‰: DB ์ฟผ๋ฆฌ vs API (์ „์ฒด+์ตœ์‹ ์ˆœ) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+

์—๋Ÿฌ์œจ: DB vs API (๋ฒ„์ŠคํŠธ + ์ง€์† ๋ถ€ํ•˜)

+
+
+
+ +
+
+

๋ฒ„์ŠคํŠธ ์‘๋‹ต์‹œ๊ฐ„: DB vs API (์ „์ฒด+์ตœ์‹ ์ˆœ, ์„ฑ๊ณต ์š”์ฒญ๋งŒ)

+
+
+
+ + + + +

๊ฒฐ๋ก 

+ +
+

ํ•ต์‹ฌ ๋ฌธ์ œ

+
    +
  • ๋ชจ๋“  ๋ชฉ๋ก ์ฟผ๋ฆฌ๊ฐ€ Full Table Scan + filesort (type=ALL, key=null)
  • +
  • ๋ฐ์ดํ„ฐ 10๋ฐฐ ์ฆ๊ฐ€ ์‹œ ์‘๋‹ต์‹œ๊ฐ„ 7~26๋ฐฐ ์ฆ๊ฐ€ (O(N) ์„ ํ˜• ์ €ํ•˜)
  • +
  • 100๋งŒ๊ฑด๋ถ€ํ„ฐ 20 RPS๋„ ๊ฐ๋‹น ๋ถˆ๊ฐ€, 1000๋งŒ๊ฑด์—์„œ ์„œ๋น„์Šค ์™„์ „ ๋ถˆ๋Šฅ
  • +
  • brandId ํ•„ํ„ฐ๊ฐ€ ์žˆ์–ด๋„ ์ธ๋ฑ์Šค ์—†์ด Full Scan ํ›„ ํ•„ํ„ฐ๋ง์ด๋ผ ๊ฐœ์„  ํšจ๊ณผ ๋ฏธ๋ฏธ
  • +
+
+ +
+

๊ฐœ์„  ๋ฐฉํ–ฅ (TO-BE)

+
    +
  • ๋ณตํ•ฉ ์ธ๋ฑ์Šค (brand_id, deleted_at, sort_col โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘) -> Full Table Scan -> range/ref scan, filesort ์ œ๊ฑฐ
  • +
  • Redis ์บ์‹œ -> ๋ฐ˜๋ณต ์กฐํšŒ ์‹œ DB ์ฟผ๋ฆฌ ์ž์ฒด๋ฅผ ํšŒํ”ผ
  • +
  • ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ (CacheLock + PER) -> ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ DB ํญ์ฃผ ๋ฐฉ์ง€
  • +
+
+ +

+ TestContainers MySQL 8.0 · ์ƒ๋Œ€์  ๋น„๊ต(AS-IS vs TO-BE, ๊ทœ๋ชจ๋ณ„ ์ฆ๊ฐ€์œจ)๊ฐ€ ํ•ต์‹ฌ ์ง€ํ‘œ +

+ + + + + + + + From 588980ec264a1dbdda880dee145a74d0ce5900ba Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:21:12 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EC=A0=84=EC=B2=B4=20Entity=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductEntity, BrandEntity, CartItemEntity ์ธ๋ฑ์Šค ์ถ”๊ฐ€ - CouponTemplateEntity, IssuedCouponEntity, ProductLikeEntity ์ธ๋ฑ์Šค ์ถ”๊ฐ€ - OrderEntity, OrderItemEntity ์ธ๋ฑ์Šค ์ถ”๊ฐ€ - CLAUDE.md 4.10-4.11 ์ธ๋ฑ์Šค ์„ค๊ณ„ ์„น์…˜ ์ถ”๊ฐ€ - .claude/skills/index-design/SKILL.md ์ธ๋ฑ์Šค ์„ค๊ณ„ ์Šคํ‚ฌ ์ถ”๊ฐ€ - apps/commerce-api/build.gradle.kts ๋ฒค์น˜๋งˆํฌ sourceSet ์„ค์ • - ProductIndexPerformanceTest, ProductApiPerformanceTest ๋ฒค์น˜๋งˆํฌ ์ถ”๊ฐ€ - round5-docs/04-to-be-index-measurement.md ์ธก์ • ๊ฒฐ๊ณผ - round5-docs/04-to-be-index-visualization.html ์‹œ๊ฐํ™” Co-Authored-By: Claude Opus 4.6 --- .claude/skills/index-design/SKILL.md | 102 ++ CLAUDE.md | 12 + apps/commerce-api/build.gradle.kts | 27 + .../ProductApiPerformanceTest.java | 698 +++++++++++++ .../ProductIndexPerformanceTest.java | 941 ++++++++++++++++++ .../infrastructure/entity/CartItemEntity.java | 12 +- .../infrastructure/entity/BrandEntity.java | 5 +- .../infrastructure/entity/ProductEntity.java | 15 +- .../entity/CouponTemplateEntity.java | 5 +- .../entity/IssuedCouponEntity.java | 10 +- .../entity/ProductLikeEntity.java | 10 +- .../infrastructure/entity/OrderEntity.java | 13 +- .../entity/OrderItemEntity.java | 9 +- round5-docs/04-to-be-index-measurement.md | 512 ++++++++++ round5-docs/04-to-be-index-visualization.html | 814 +++++++++++++++ 15 files changed, 3159 insertions(+), 26 deletions(-) create mode 100644 .claude/skills/index-design/SKILL.md create mode 100644 apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductApiPerformanceTest.java create mode 100644 apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductIndexPerformanceTest.java create mode 100644 round5-docs/04-to-be-index-measurement.md create mode 100644 round5-docs/04-to-be-index-visualization.html diff --git a/.claude/skills/index-design/SKILL.md b/.claude/skills/index-design/SKILL.md new file mode 100644 index 000000000..06a098b11 --- /dev/null +++ b/.claude/skills/index-design/SKILL.md @@ -0,0 +1,102 @@ +--- +name: index-design +description: Database index design guidelines for JPA entities. Use when adding or reviewing composite indexes, analyzing query performance, or designing new table schemas. +--- + +# Index Design + +## 1. ์ปฌ๋Ÿผ ์ˆœ์„œ ์›์น™ โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ์šฐ์„  + +๋ณตํ•ฉ ์ธ๋ฑ์Šค์˜ ์ปฌ๋Ÿผ ์ˆœ์„œ๋Š” ๋‹ค์Œ ๊ทœ์น™์„ ๋”ฐ๋ฅธ๋‹ค: + +``` +[์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ equality ์ปฌ๋Ÿผ] โ†’ [์นด๋””๋„๋ฆฌํ‹ฐ ๋‚ฎ์€ equality ์ปฌ๋Ÿผ] โ†’ [sort/range ์ปฌ๋Ÿผ] +``` + +| ์ˆœ์„œ | ๊ธฐ์ค€ | ์ด์œ  | +|:----:|------|------| +| 1st | ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๊ฐ€์žฅ ๋†’์€ equality ์ปฌ๋Ÿผ | B-tree ์ฒซ ๋ ˆ๋ฒจ fan-out ๊ท ๋“ฑํ™” | +| 2nd | ๋‚˜๋จธ์ง€ equality ์ปฌ๋Ÿผ | ์—ฐ์† ํƒ์ƒ‰์œผ๋กœ range ์ถ•์†Œ | +| Last | ORDER BY / range scan ๋Œ€์ƒ ์ปฌ๋Ÿผ | ์ธ๋ฑ์Šค ์ˆœ์„œ = ์ •๋ ฌ ์ˆœ์„œ โ†’ filesort ์ œ๊ฑฐ | + +### ์™œ ์นด๋””๋„๋ฆฌํ‹ฐ ์ˆœ์ธ๊ฐ€? + +- ๋ณตํ•ฉ ์ธ๋ฑ์Šค์—์„œ **๋ชจ๋“  equality ์ปฌ๋Ÿผ์ด ์ฟผ๋ฆฌ์— ์‚ฌ์šฉ๋˜๋ฉด**, ์ปฌ๋Ÿผ ์ˆœ์„œ์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ matching rows๋Š” ๋™์ผ +- ํ•˜์ง€๋งŒ ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ ์ปฌ๋Ÿผ์ด ์„ ๋‘์— ์˜ค๋ฉด **B-tree ๋ถ„๊ธฐ๊ฐ€ ๊ท ๋“ฑํ•ด์ ธ ์ธ๋ฑ์Šค ํŽ˜์ด์ง€ ์ ‘๊ทผ ํšจ์œจ์ด ํ–ฅ์ƒ**๋จ +- ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋‚ฎ์€ ์ปฌ๋Ÿผ(์˜ˆ: `deleted_at` โ€” 2๊ฐ’)์ด ์„ ๋‘์— ์˜ค๋ฉด B-tree ์ฒซ ๋ ˆ๋ฒจ์ด ํŽธํ–ฅ๋จ + +### ์˜ˆ์‹œ + +``` +์ฟผ๋ฆฌ: WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY created_at DESC + +brand_id ์นด๋””๋„๋ฆฌํ‹ฐ: ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ (๋†’์Œ) +deleted_at ์นด๋””๋„๋ฆฌํ‹ฐ: 2 (NULL / timestamp, ๋‚ฎ์Œ) + +โœ… (brand_id, deleted_at, created_at) โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘ +โŒ (deleted_at, brand_id, created_at) โ€” ์นด๋””๋„๋ฆฌํ‹ฐ ๋‚ฎ์€ deleted_at ์„ ๋‘ +``` + +## 2. IS NULL๊ณผ ์ธ๋ฑ์Šค + +- MySQL์—์„œ `IS NULL`์€ **equality(ref)** ๋กœ ์ฒ˜๋ฆฌ๋จ (range๊ฐ€ ์•„๋‹˜) +- `deleted_at IS NULL`์ด ์ „์ฒด์˜ 99%+์—ฌ๋„ ์ธ๋ฑ์Šค ์‚ฌ์šฉ ๊ฐ€๋Šฅ +- ๋‹จ, ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ 2๋ฟ์ด๋ฏ€๋กœ **์„ ๋‘ ์ปฌ๋Ÿผ์œผ๋กœ๋Š” ๋น„ํšจ์œจ์ ** โ†’ equality ์ปฌ๋Ÿผ ์ค‘ ํ›„์ˆœ์œ„์— ๋ฐฐ์น˜ + +## 3. ์ฟผ๋ฆฌ ์กฐํ•ฉ๋ณ„ ์ธ๋ฑ์Šค ์ปค๋ฒ„๋ฆฌ์ง€ + +์กฐํšŒ API์— ์ •๋ ฌ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ, **๊ฐ€๋Šฅํ•œ ๋ชจ๋“  WHERE + ORDER BY ์กฐํ•ฉ**์— ๋Œ€ํ•ด ์ธ๋ฑ์Šค๋ฅผ ์„ค๊ณ„ํ•œ๋‹ค. + +``` +์˜ˆ: ์‚ฌ์šฉ์ž/๊ด€๋ฆฌ์ž ร— ๋ธŒ๋žœ๋“œ์œ ๋ฌด ร— N๊ฐœ ์ •๋ ฌ = 2 ร— 2 ร— N๊ฐœ ์ธ๋ฑ์Šค +``` + +### ์กฐํ•ฉ์ด ๋งŽ์•„์ง€๋Š” ๊ฒฝ์šฐ + +| ์กฐ๊ฑด | ์ธ๋ฑ์Šค ์„ค๊ณ„ | +|------|-----------| +| WHERE A AND B ORDER BY C | `(A, B, C)` โ€” equality 2๊ฐœ + sort 1๊ฐœ | +| WHERE A ORDER BY C (B ์—†์Œ) | `(A, C)` โ€” ๋ณ„๋„ 2-column ์ธ๋ฑ์Šค ํ•„์š” | +| WHERE B ORDER BY C (A ์—†์Œ) | `(B, C)` โ€” ๋ณ„๋„ 2-column ์ธ๋ฑ์Šค ํ•„์š” | +| ORDER BY C (ํ•„ํ„ฐ ์—†์Œ) | `(C)` โ€” ๋‹จ์ผ ์ปฌ๋Ÿผ ์ธ๋ฑ์Šค ํ•„์š” | + +**์ค‘๊ฐ„ ์ปฌ๋Ÿผ ์Šคํ‚ต ์‹œ ์ •๋ ฌ ๋ถˆ๊ฐ€**: `(A, B, C)` ์ธ๋ฑ์Šค์—์„œ `WHERE A ORDER BY C`๋Š” B๋ฅผ ๊ฑด๋„ˆ๋›ธ ์ˆ˜ ์—†์–ด **filesort ๋ฐœ์ƒ**. ๋ฐ˜๋“œ์‹œ ๋ณ„๋„ `(A, C)` ์ธ๋ฑ์Šค ํ•„์š”. + +## 4. JPA Entity ์„ ์–ธ ๋ฐฉ์‹ + +```java +@Table(name = "table_name", indexes = { + // ์‚ฌ์šฉ์ž ์กฐํšŒ: WHERE brand_id = ? AND deleted_at IS NULL ORDER BY created_at DESC + // ์นด๋””๋„๋ฆฌํ‹ฐ: brand_id(๋†’์Œ) โ†’ deleted_at(๋‚ฎ์Œ) โ†’ sort_col + @Index(name = "idx_{table}_{col1}_{col2}_{col3}", columnList = "brand_id, deleted_at, created_at"), +}) +``` + +### ๋„ค์ด๋ฐ ๊ทœ์น™ + +| ์œ ํ˜• | ํŒจํ„ด | ์˜ˆ์‹œ | +|------|------|------| +| ๋ณตํ•ฉ ์ธ๋ฑ์Šค | `idx_{table}_{col1}_{col2}_{col3}` | `idx_read_brand_deleted_created` | +| ๋‹จ์ผ ์ธ๋ฑ์Šค | `idx_{table}_{col}` | `idx_read_created` | + +### ์ฃผ์„ ๊ทœ์น™ + +- ๊ฐ ์ธ๋ฑ์Šค ์œ„์— **๋Œ€์ƒ ์ฟผ๋ฆฌ ํŒจํ„ด**์„ ์ฃผ์„์œผ๋กœ ๋ช…์‹œ +- ์นด๋””๋„๋ฆฌํ‹ฐ ์ˆœ์„œ๊ฐ€ ์ผ๋ฐ˜์ ์ด์ง€ ์•Š์€ ๊ฒฝ์šฐ ๊ทธ ์ด์œ ๋ฅผ ์ฃผ์„์œผ๋กœ ์„ค๋ช… + +## 5. ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ์ฒดํฌ๋ฆฌ์ŠคํŠธ + +์ธ๋ฑ์Šค๋ฅผ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ๋ฆฌ๋ทฐํ•  ๋•Œ ๋‹ค์Œ์„ ํ™•์ธํ•œ๋‹ค: + +- [ ] equality ์ปฌ๋Ÿผ์ด range/sort ์ปฌ๋Ÿผ๋ณด๋‹ค ์•ž์— ์œ„์น˜ํ•˜๋Š”๊ฐ€? +- [ ] equality ์ปฌ๋Ÿผ ๊ฐ„ ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ ์ˆœ์œผ๋กœ ๋ฐฐ์น˜๋˜์—ˆ๋Š”๊ฐ€? +- [ ] ์„ ํƒ์  ํ•„ํ„ฐ(optional WHERE)๊ฐ€ ๋น ์ง„ ์ฟผ๋ฆฌ์—๋„ ๋ณ„๋„ ์ธ๋ฑ์Šค๊ฐ€ ์กด์žฌํ•˜๋Š”๊ฐ€? +- [ ] ์ค‘๊ฐ„ ์ปฌ๋Ÿผ ์Šคํ‚ต์œผ๋กœ ์ธํ•œ filesort ๋ฐœ์ƒ ๊ฐ€๋Šฅ์„ฑ์„ ๊ฒ€ํ† ํ–ˆ๋Š”๊ฐ€? +- [ ] ์ธ๋ฑ์Šค ์ฃผ์„์— ๋Œ€์ƒ ์ฟผ๋ฆฌ ํŒจํ„ด์ด ๋ช…์‹œ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- [ ] ์ธ๋ฑ์Šค ์ˆ˜๊ฐ€ ๊ณผ๋„ํ•˜์ง€ ์•Š์€๊ฐ€? (์“ฐ๊ธฐ ๋น„์šฉ trade-off ๊ฒ€ํ† ) + +## 6. Read Model ํŒจํ„ด์—์„œ์˜ ์ธ๋ฑ์Šค + +- Read Model ํ…Œ์ด๋ธ”์€ **์กฐํšŒ ์ „์šฉ**์ด๋ฏ€๋กœ ์ธ๋ฑ์Šค๋ฅผ ์ž์œ ๋กญ๊ฒŒ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ +- ์›๋ณธ ํ…Œ์ด๋ธ”(์“ฐ๊ธฐ ์ „์šฉ)์—๋Š” ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ์‹œ INSERT/UPDATE ๋น„์šฉ ์ฆ๊ฐ€ ๊ณ ๋ ค +- Read Model์— ๋น„์ •๊ทœํ™”๋œ ์ปฌ๋Ÿผ(์˜ˆ: `brand_name`)์ด ์žˆ์œผ๋ฉด JOIN ์ œ๊ฑฐ ๊ฐ€๋Šฅ โ†’ ์ธ๋ฑ์Šค ํšจ๊ณผ ๊ทน๋Œ€ํ™” diff --git a/CLAUDE.md b/CLAUDE.md index 8ecd7b407..557e81580 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -527,6 +527,18 @@ Controller โ†’ Facade โ†’ Service / Domain Service โ†’ Repository(interface) โ†’ - ์‚ฌ์šฉ์ž ์ „์šฉ DTO: ์ ‘๋‘์‚ฌ ์—†์Œ (์˜ˆ: `BrandOutDto`, `BrandResponse`) - ๊ด€๋ฆฌ์ž/์‚ฌ์šฉ์ž ์‘๋‹ต ํ•„๋“œ๊ฐ€ ๋‹ค๋ฅผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋ณ„๋„ DTO ์ •์˜ (์˜ˆ: ๊ด€๋ฆฌ์ž๋Š” `deletedAt` ํฌํ•จ) +### 4.10 DB ์Šคํ‚ค๋งˆ ๊ด€๋ฆฌ + +- **Flyway ๋“ฑ DB ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋„๊ตฌ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ** โ€” Hibernate `ddl-auto`๋กœ ์Šคํ‚ค๋งˆ ๊ด€๋ฆฌ +- JPA Entity์˜ `@Column`, `@Table(indexes = {...})` ์„ ์–ธ์ด ์Šคํ‚ค๋งˆ์˜ SoT +- ์ปฌ๋Ÿผ ์ถ”๊ฐ€/์ œ๊ฑฐ๋Š” Entity ํ•„๋“œ ๋ณ€๊ฒฝ์œผ๋กœ ๋ฐ˜์˜ (๋ณ„๋„ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ํŒŒ์ผ ๋ถˆํ•„์š”) + +### 4.11 ์ธ๋ฑ์Šค ์„ค๊ณ„ + +- **์ธ๋ฑ์Šค ์ƒ์„ฑ ์‹œ** `.claude/skills/index-design/SKILL.md` ๊ทœ์น™์„ ๋ฐ˜๋“œ์‹œ ์ค€์ˆ˜ +- ํ•ต์‹ฌ ์›์น™: **์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ ์ปฌ๋Ÿผ์„ ์„ ๋‘์— ๋ฐฐ์น˜**, equality ์ปฌ๋Ÿผ์€ range/sort ์ปฌ๋Ÿผ๋ณด๋‹ค ์•ž์— +- `@Table(indexes = { @Index(...) })` ๋ฐฉ์‹์œผ๋กœ JPA Entity์— ์„ ์–ธ (Hibernate auto-DDL ํ™œ์šฉ) + ## 5. ์ฃผ์˜์‚ฌํ•ญ ### Never Do diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index de9b02f75..913430b44 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -1,3 +1,30 @@ +import org.gradle.api.tasks.testing.Test + +val benchmarkSourceSet = sourceSets.create("benchmark") { + java.srcDir("src/benchmark/java") + resources.srcDir("src/benchmark/resources") + compileClasspath += sourceSets["main"].output + runtimeClasspath += output + compileClasspath +} + +configurations[benchmarkSourceSet.implementationConfigurationName].extendsFrom(configurations.testImplementation.get()) +configurations[benchmarkSourceSet.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get()) +configurations[benchmarkSourceSet.compileOnlyConfigurationName].extendsFrom(configurations.testCompileOnly.get()) +configurations[benchmarkSourceSet.annotationProcessorConfigurationName].extendsFrom(configurations.testAnnotationProcessor.get()) + +tasks.register("benchmarkTest") { + description = "Runs benchmark-style performance tests excluded from the default test task." + group = "verification" + testClassesDirs = benchmarkSourceSet.output.classesDirs + classpath = benchmarkSourceSet.runtimeClasspath + maxParallelForks = 1 + useJUnitPlatform() + systemProperty("user.timezone", "Asia/Seoul") + systemProperty("spring.profiles.active", "test") + jvmArgs("-Xshare:off") + shouldRunAfter(tasks.test) +} + dependencies { // add-ons implementation(project(":modules:jpa")) diff --git a/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductApiPerformanceTest.java b/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductApiPerformanceTest.java new file mode 100644 index 000000000..579c6848b --- /dev/null +++ b/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductApiPerformanceTest.java @@ -0,0 +1,698 @@ +package com.loopers.catalog.product.infrastructure; + + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import javax.sql.DataSource; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.sql.*; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + + +/** + * ์ƒํ’ˆ API ๋ ˆ๋ฒจ ์„ฑ๋Šฅ ์ธก์ • (NO-CACHE / CACHE) + * - MockMvc๋ฅผ ํ†ตํ•ด ์‹ค์ œ Controller โ†’ Facade โ†’ Service โ†’ Repository โ†’ DB ์ „์ฒด ์Šคํƒ ์ธก์ • + * - NO-CACHE: ์ธ๋ฑ์Šค๋งŒ ์ ์šฉ, Redis ์บ์‹œ ๋ฏธ์ ์šฉ ์ƒํƒœ์˜ ๊ธฐ์ค€์„  + * - CACHE (TO-BE): ์ธ๋ฑ์Šค + Redis ์บ์‹œ ์ ์šฉ ์ƒํƒœ (์บ์‹œ ํžˆํŠธ/๋ฏธ์Šค ๋ถ„๋ฆฌ ์ธก์ •) + * - ์ธก์ • ์ถ•: ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ(10๋งŒ/100๋งŒ/1000๋งŒ) ร— ํŠธ๋ž˜ํ”ฝ ์œ ํ˜•(๋‹จ์ผ์ฟผ๋ฆฌ/๋ฒ„์ŠคํŠธ/์ง€์†๋ถ€ํ•˜) + * + * 1. ๋‹จ์ผ ์ฟผ๋ฆฌ: ๋ชฉ๋ก/์ƒ์„ธ API (warmup 3ํšŒ + ์ธก์ • 5ํšŒ ํ‰๊ท ) + * 2. ๋ฒ„์ŠคํŠธ: 100 concurrent ๋™์‹œ ์š”์ฒญ โ†’ p50/p95/p99 + * 3. ์ง€์† ๋ถ€ํ•˜: 20 RPS ร— 10์ดˆ โ†’ ์ฒ˜๋ฆฌ๋Ÿ‰ + p50/p95/p99 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@DisplayName("์ƒํ’ˆ API ์„ฑ๋Šฅ ์ธก์ •") +class ProductApiPerformanceTest { + + private static final Logger log = LoggerFactory.getLogger(ProductApiPerformanceTest.class); + private static final String RESULT_FILE = "/tmp/api-perf-results.txt"; + private static final int BRAND_COUNT = 50; + + // ๋ฒ„์ŠคํŠธ ํŒŒ๋ผ๋ฏธํ„ฐ + private static final int BURST_THREADS = 100; + + // ์ง€์† ๋ถ€ํ•˜ ํŒŒ๋ผ๋ฏธํ„ฐ + private static final int SUSTAINED_RPS = 20; + private static final int SUSTAINED_DURATION_SEC = 10; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private DataSource dataSource; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + + // --- ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ (๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„) --- + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + @DisplayName("[NO-CACHE API] 10๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค๋งŒ, ์บ์‹œ ๋ฏธ์ ์šฉ)") + void measureApiNoCache_100K() throws Exception { + runMeasurement(100_000, "100K", 1_000); + } + + + @Test + @Timeout(value = 15, unit = TimeUnit.MINUTES) + @DisplayName("[NO-CACHE API] 100๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค๋งŒ, ์บ์‹œ ๋ฏธ์ ์šฉ)") + void measureApiNoCache_1M() throws Exception { + runMeasurement(1_000_000, "1M", 5_000); + } + + + @Test + @Timeout(value = 30, unit = TimeUnit.MINUTES) + @DisplayName("[NO-CACHE API] 1000๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค๋งŒ, ์บ์‹œ ๋ฏธ์ ์šฉ)") + void measureApiNoCache_10M() throws Exception { + runMeasurement(10_000_000, "10M", 10_000); + } + + + // --- CACHE ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ, ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„) --- + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + @DisplayName("[CACHE API] 10๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ)") + void measureApiCache_100K() throws Exception { + runToBeMeasurement(100_000, "100K", 1_000); + } + + + @Test + @Timeout(value = 15, unit = TimeUnit.MINUTES) + @DisplayName("[CACHE API] 100๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ)") + void measureApiCache_1M() throws Exception { + runToBeMeasurement(1_000_000, "1M", 5_000); + } + + + @Test + @Timeout(value = 30, unit = TimeUnit.MINUTES) + @DisplayName("[CACHE API] 1000๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ)") + void measureApiCache_10M() throws Exception { + runToBeMeasurement(10_000_000, "10M", 10_000); + } + + + // --- ํ•ต์‹ฌ ์ธก์ • ํ๋ฆ„ --- + + private void runMeasurement(int productCount, String label, int batchSize) throws Exception { + // ๊ฒฐ๊ณผ ํŒŒ์ผ ์ดˆ๊ธฐํ™” + try (PrintWriter pw = new PrintWriter(new FileWriter(RESULT_FILE, false))) { + pw.println("=== NO-CACHE API Performance Results ==="); + } catch (IOException e) { + // ๋ฌด์‹œ + } + + out("\n========================================"); + out(String.format("[%s] NO-CACHE API ์„ฑ๋Šฅ ์ธก์ • ์‹œ์ž‘ (์ธ๋ฑ์Šค๋งŒ, ์บ์‹œ ๋ฏธ์ ์šฉ, ๋ธŒ๋žœ๋“œ %d๊ฐœ, ์ƒํ’ˆ %d๊ฑด)", label, BRAND_COUNT, productCount)); + out("========================================"); + + // 1. ๋ฐ์ดํ„ฐ ์ค€๋น„ (products + product_read_model โ€” ์•ฑ ์ฝ”๋“œ๊ฐ€ read_model ์กฐํšŒ) + long insertStart = System.currentTimeMillis(); + insertBulkData(productCount, batchSize); + insertReadModelFromProducts(); + long insertElapsed = System.currentTimeMillis() - insertStart; + out(String.format("[%s] ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์™„๋ฃŒ: %dms", label, insertElapsed)); + + // ANALYZE TABLE๋กœ MySQL ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("ANALYZE TABLE products"); + stmt.execute("ANALYZE TABLE brands"); + stmt.execute("ANALYZE TABLE product_read_model"); + } + + // Redis ์ดˆ๊ธฐํ™” (์บ์‹œ ๋ฏธ์ ์šฉ ์ƒํƒœ ๋ณด์žฅ) + redisCleanUp.truncateAll(); + + // 2. API ์—”๋“œํฌ์ธํŠธ ์ •์˜ + // ๋ชฉ๋ก API โ€” 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค + String[][] listApis = { + {"๋ชฉ๋ก UC1: brandId=X, LATEST", "/api/v1/products?sort=LATEST&page=0&size=20"}, + {"๋ชฉ๋ก UC2: brandId=X, PRICE_ASC", "/api/v1/products?sort=PRICE_ASC&page=0&size=20"}, + {"๋ชฉ๋ก UC3: brandId=X, LIKES_DESC", "/api/v1/products?sort=LIKES_DESC&page=0&size=20"}, + {"๋ชฉ๋ก UC4: brandId=1, LATEST", "/api/v1/products?brandId=1&sort=LATEST&page=0&size=20"}, + {"๋ชฉ๋ก UC5: brandId=1, PRICE_ASC", "/api/v1/products?brandId=1&sort=PRICE_ASC&page=0&size=20"}, + {"๋ชฉ๋ก UC6: brandId=1, LIKES_DESC", "/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20"}, + }; + + // ์ƒ์„ธ API โ€” ์ƒํ’ˆ ID 1๋ฒˆ (ํ™•์‹คํžˆ ์กด์žฌํ•˜๋Š” ๋ฐ์ดํ„ฐ) + String[][] detailApis = { + {"์ƒ์„ธ: productId=1", "/api/v1/products/1"}, + }; + + // 3. ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • (๋งค ์š”์ฒญ๋งˆ๋‹ค Redis ์ดˆ๊ธฐํ™” โ€” ์บ์‹œ ๋ฏธ์ ์šฉ ๋ณด์žฅ) + out(String.format("\n[%s] ===== NO-CACHE API ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • =====", label)); + for (String[] api : listApis) { + measureSingleApiNoCache(label + " NO-CACHE", api[0], api[1]); + } + for (String[] api : detailApis) { + measureSingleApiNoCache(label + " NO-CACHE", api[0], api[1]); + } + + // 4. ๋ฒ„์ŠคํŠธ ์ธก์ • (๋Œ€ํ‘œ UC: UC1, UC3, UC4, ์ƒ์„ธ) + out(String.format("\n[%s] ===== NO-CACHE API ๋ฒ„์ŠคํŠธ ์ธก์ • (%d๊ฑด ๋™์‹œ) =====", label, BURST_THREADS)); + redisCleanUp.truncateAll(); + measureBurst(label + " NO-CACHE", listApis[0][0], listApis[0][1]); + redisCleanUp.truncateAll(); + measureBurst(label + " NO-CACHE", listApis[2][0], listApis[2][1]); + redisCleanUp.truncateAll(); + measureBurst(label + " NO-CACHE", listApis[3][0], listApis[3][1]); + redisCleanUp.truncateAll(); + measureBurst(label + " NO-CACHE", detailApis[0][0], detailApis[0][1]); + + // 5. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (๋Œ€ํ‘œ UC) + out(String.format("\n[%s] ===== NO-CACHE API ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (%d RPS ร— %d์ดˆ) =====", label, SUSTAINED_RPS, SUSTAINED_DURATION_SEC)); + redisCleanUp.truncateAll(); + measureSustainedLoad(label + " NO-CACHE", listApis[0][0], listApis[0][1]); + redisCleanUp.truncateAll(); + measureSustainedLoad(label + " NO-CACHE", listApis[2][0], listApis[2][1]); + redisCleanUp.truncateAll(); + measureSustainedLoad(label + " NO-CACHE", listApis[3][0], listApis[3][1]); + redisCleanUp.truncateAll(); + measureSustainedLoad(label + " NO-CACHE", detailApis[0][0], detailApis[0][1]); + + out(String.format("\n[%s] ===== NO-CACHE API ์ธก์ • ์™„๋ฃŒ =====", label)); + } + + + // --- CACHE ํ•ต์‹ฌ ์ธก์ • ํ๋ฆ„ (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ) --- + + private void runToBeMeasurement(int productCount, String label, int batchSize) throws Exception { + // ๊ฒฐ๊ณผ ํŒŒ์ผ ์ดˆ๊ธฐํ™” + try (PrintWriter pw = new PrintWriter(new FileWriter(RESULT_FILE, false))) { + pw.println("=== CACHE API Performance Results ==="); + } catch (IOException e) { + // ๋ฌด์‹œ + } + + out("\n========================================"); + out(String.format("[%s] CACHE API ์„ฑ๋Šฅ ์ธก์ • ์‹œ์ž‘ (์ธ๋ฑ์Šค + ์บ์‹œ ์ ์šฉ, ๋ธŒ๋žœ๋“œ %d๊ฐœ, ์ƒํ’ˆ %d๊ฑด)", label, BRAND_COUNT, productCount)); + out("========================================"); + + // 1. ๋ฐ์ดํ„ฐ ์ค€๋น„ (products + product_read_model) + long insertStart = System.currentTimeMillis(); + insertBulkData(productCount, batchSize); + insertReadModelFromProducts(); + long insertElapsed = System.currentTimeMillis() - insertStart; + out(String.format("[%s] ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์™„๋ฃŒ: %dms", label, insertElapsed)); + + // ANALYZE TABLE๋กœ MySQL ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("ANALYZE TABLE products"); + stmt.execute("ANALYZE TABLE brands"); + stmt.execute("ANALYZE TABLE product_read_model"); + } + + // 2. API ์—”๋“œํฌ์ธํŠธ ์ •์˜ (NO-CACHE์™€ ๋™์ผ) + String[][] listApis = { + {"๋ชฉ๋ก UC1: brandId=X, LATEST", "/api/v1/products?sort=LATEST&page=0&size=20"}, + {"๋ชฉ๋ก UC2: brandId=X, PRICE_ASC", "/api/v1/products?sort=PRICE_ASC&page=0&size=20"}, + {"๋ชฉ๋ก UC3: brandId=X, LIKES_DESC", "/api/v1/products?sort=LIKES_DESC&page=0&size=20"}, + {"๋ชฉ๋ก UC4: brandId=1, LATEST", "/api/v1/products?brandId=1&sort=LATEST&page=0&size=20"}, + {"๋ชฉ๋ก UC5: brandId=1, PRICE_ASC", "/api/v1/products?brandId=1&sort=PRICE_ASC&page=0&size=20"}, + {"๋ชฉ๋ก UC6: brandId=1, LIKES_DESC", "/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20"}, + }; + String[][] detailApis = { + {"์ƒ์„ธ: productId=1", "/api/v1/products/1"}, + }; + + // 3. ์บ์‹œ ๋ฏธ์Šค ์ธก์ • (Redis ๋น„์šด ์ƒํƒœ์—์„œ ์ฒซ ํ˜ธ์ถœ) + out(String.format("\n[%s] ===== CACHE API ์บ์‹œ ๋ฏธ์Šค ์ธก์ • (์ฒซ ํ˜ธ์ถœ) =====", label)); + for (String[] api : listApis) { + measureSingleApiMiss(label + " MISS", api[0], api[1]); + } + for (String[] api : detailApis) { + measureSingleApiMiss(label + " MISS", api[0], api[1]); + } + + // 4. ์บ์‹œ ํžˆํŠธ ์ธก์ • (์บ์‹œ ์›Œ๋ฐ ํ›„ ๋ฐ˜๋ณต ํ˜ธ์ถœ) + out(String.format("\n[%s] ===== CACHE API ์บ์‹œ ํžˆํŠธ ์ธก์ • (์บ์‹œ ์›Œ๋ฐ ํ›„) =====", label)); + for (String[] api : listApis) { + measureSingleApiHit(label + " HIT", api[0], api[1]); + } + for (String[] api : detailApis) { + measureSingleApiHit(label + " HIT", api[0], api[1]); + } + + // 5. ๋ฒ„์ŠคํŠธ ์ธก์ • โ€” ์บ์‹œ ํžˆํŠธ ์ƒํƒœ์—์„œ (์บ์‹œ๊ฐ€ ์ด๋ฏธ ์›Œ๋ฐ๋œ ์ƒํƒœ) + out(String.format("\n[%s] ===== CACHE API ๋ฒ„์ŠคํŠธ ์ธก์ • (์บ์‹œ ํžˆํŠธ, %d๊ฑด ๋™์‹œ) =====", label, BURST_THREADS)); + measureBurst(label + " HIT", listApis[0][0], listApis[0][1]); + measureBurst(label + " HIT", listApis[2][0], listApis[2][1]); + measureBurst(label + " HIT", listApis[3][0], listApis[3][1]); + measureBurst(label + " HIT", detailApis[0][0], detailApis[0][1]); + + // 6. ๋ฒ„์ŠคํŠธ ์ธก์ • โ€” ์บ์‹œ ๋ฏธ์Šค ์ƒํƒœ์—์„œ (์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ ๊ฒ€์ฆ) + out(String.format("\n[%s] ===== CACHE API ๋ฒ„์ŠคํŠธ ์ธก์ • (์บ์‹œ ๋ฏธ์Šค โ€” ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ, %d๊ฑด ๋™์‹œ) =====", label, BURST_THREADS)); + redisCleanUp.truncateAll(); + measureBurst(label + " MISS", listApis[0][0], listApis[0][1]); + redisCleanUp.truncateAll(); + measureBurst(label + " MISS", detailApis[0][0], detailApis[0][1]); + + // 7. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • โ€” ์บ์‹œ ํžˆํŠธ ์ƒํƒœ + out(String.format("\n[%s] ===== CACHE API ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (์บ์‹œ ํžˆํŠธ, %d RPS ร— %d์ดˆ) =====", label, SUSTAINED_RPS, SUSTAINED_DURATION_SEC)); + // ์บ์‹œ ์›Œ๋ฐ (์ง€์† ๋ถ€ํ•˜ ๋Œ€ํ‘œ UC) + executeSuccessfulRequest(listApis[0][1]); + executeSuccessfulRequest(listApis[2][1]); + executeSuccessfulRequest(listApis[3][1]); + executeSuccessfulRequest(detailApis[0][1]); + measureSustainedLoad(label + " HIT", listApis[0][0], listApis[0][1]); + measureSustainedLoad(label + " HIT", listApis[2][0], listApis[2][1]); + measureSustainedLoad(label + " HIT", listApis[3][0], listApis[3][1]); + measureSustainedLoad(label + " HIT", detailApis[0][0], detailApis[0][1]); + + out(String.format("\n[%s] ===== CACHE API ์ธก์ • ์™„๋ฃŒ =====", label)); + } + + + // --- ๋‹จ์ผ API ์ธก์ • (NO-CACHE: ๋งค ์š”์ฒญ๋งˆ๋‹ค Redis ์ดˆ๊ธฐํ™”) --- + + private void measureSingleApiNoCache(String dataLabel, String ucLabel, String url) throws Exception { + // Warmup (3ํšŒ) โ€” Spring ์ปจํ…์ŠคํŠธ/JPA ์„ธ์…˜/์ปค๋„ฅ์…˜ ํ’€ ์•ˆ์ •ํ™” + for (int w = 0; w < 3; w++) { + executeSuccessfulRequest(url); + } + + // ์‹คํ–‰์‹œ๊ฐ„ ์ธก์ • (5ํšŒ, ๋งค ์š”์ฒญ ์ „ Redis ์ดˆ๊ธฐํ™”) + int runs = 5; + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + redisCleanUp.truncateAll(); + times[i] = measureRequestLatency(url); + } + + long sum = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long t : times) { + sum += t; + min = Math.min(min, t); + max = Math.max(max, t); + } + + out(String.format("[%s] [API ๋‹จ์ผ] %s โ€” avg=%sms, min=%sms, max=%sms", + dataLabel, ucLabel, + String.format("%.2f", sum / (double) runs / 1_000_000.0), + String.format("%.2f", min / 1_000_000.0), + String.format("%.2f", max / 1_000_000.0))); + } + + + // --- ๋‹จ์ผ API ์ธก์ • (CACHE: warmup ํ›„ ์บ์‹œ ํžˆํŠธ ์ƒํƒœ) --- + + private void measureSingleApiHit(String dataLabel, String ucLabel, String url) throws Exception { + redisCleanUp.truncateAll(); + executeSuccessfulRequest(url); + + for (int w = 0; w < 3; w++) { + executeSuccessfulRequest(url); + } + + int runs = 5; + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + times[i] = measureRequestLatency(url); + } + + long sum = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long t : times) { + sum += t; + min = Math.min(min, t); + max = Math.max(max, t); + } + + out(String.format("[%s] [API ๋‹จ์ผ] %s โ€” avg=%sms, min=%sms, max=%sms", + dataLabel, ucLabel, + String.format("%.2f", sum / (double) runs / 1_000_000.0), + String.format("%.2f", min / 1_000_000.0), + String.format("%.2f", max / 1_000_000.0))); + } + + + private void measureSingleApiMiss(String dataLabel, String ucLabel, String url) throws Exception { + for (int w = 0; w < 3; w++) { + redisCleanUp.truncateAll(); + executeSuccessfulRequest(url); + } + + int runs = 5; + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + redisCleanUp.truncateAll(); + times[i] = measureRequestLatency(url); + } + + long sum = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long t : times) { + sum += t; + min = Math.min(min, t); + max = Math.max(max, t); + } + + out(String.format("[%s] [API ๋‹จ์ผ] %s โ€” avg=%sms, min=%sms, max=%sms", + dataLabel, ucLabel, + String.format("%.2f", sum / (double) runs / 1_000_000.0), + String.format("%.2f", min / 1_000_000.0), + String.format("%.2f", max / 1_000_000.0))); + } + + + // --- ๋ฒ„์ŠคํŠธ ์ธก์ • (N๊ฐœ ๋™์‹œ ์š”์ฒญ) --- + + private void measureBurst(String dataLabel, String ucLabel, String url) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(BURST_THREADS); + CountDownLatch ready = new CountDownLatch(BURST_THREADS); + CountDownLatch go = new CountDownLatch(1); + long[] latencies = new long[BURST_THREADS]; + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < BURST_THREADS; i++) { + final int idx = i; + executor.submit(() -> { + ready.countDown(); + try { + go.await(); + latencies[idx] = measureRequestLatency(url); + } catch (Exception e) { + errors.incrementAndGet(); + latencies[idx] = -1; + } + }); + } + + ready.await(); + go.countDown(); + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.MINUTES); + + long[] valid = Arrays.stream(latencies).filter(l -> l > 0).sorted().toArray(); + if (valid.length > 0) { + out(String.format("[%s] [API ๋ฒ„์ŠคํŠธ] %s โ€” ์™„๋ฃŒ: %d/%d, ์—๋Ÿฌ: %d, avg=%sms, p50=%sms, p95=%sms, p99=%sms, max=%sms", + dataLabel, ucLabel, + valid.length, BURST_THREADS, errors.get(), + String.format("%.2f", avg(valid)), + String.format("%.2f", percentile(valid, 50)), + String.format("%.2f", percentile(valid, 95)), + String.format("%.2f", percentile(valid, 99)), + String.format("%.2f", valid[valid.length - 1] / 1_000_000.0))); + } else { + out(String.format("[%s] [API ๋ฒ„์ŠคํŠธ] %s โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: %d)", dataLabel, ucLabel, errors.get())); + } + } + + + // --- ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (N RPS ร— T์ดˆ) --- + + private void measureSustainedLoad(String dataLabel, String ucLabel, String url) throws Exception { + int totalRequests = SUSTAINED_RPS * SUSTAINED_DURATION_SEC; + long intervalMs = 1000 / SUSTAINED_RPS; + + ExecutorService executor = Executors.newFixedThreadPool(SUSTAINED_RPS * 2); + List latencies = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger errors = new AtomicInteger(0); + + long testStart = System.nanoTime(); + + for (int i = 0; i < totalRequests; i++) { + executor.submit(() -> { + long start = System.nanoTime(); + try { + executeSuccessfulRequest(url); + latencies.add(System.nanoTime() - start); + } catch (Exception e) { + errors.incrementAndGet(); + } + }); + + // Rate limiting + long elapsed = (System.nanoTime() - testStart) / 1_000_000; + long expected = (long) (i + 1) * intervalMs; + long sleepMs = expected - elapsed; + if (sleepMs > 0) { + Thread.sleep(sleepMs); + } + } + + executor.shutdown(); + boolean finished = executor.awaitTermination(5, TimeUnit.MINUTES); + + long totalTimeMs = (System.nanoTime() - testStart) / 1_000_000; + + long[] sorted = latencies.stream().mapToLong(l -> l).sorted().toArray(); + if (sorted.length > 0) { + double actualQps = (double) sorted.length / totalTimeMs * 1000; + out(String.format("[%s] [API ์ง€์†๋ถ€ํ•˜] %s โ€” ์™„๋ฃŒ: %d/%d, ์—๋Ÿฌ: %d, ์‹ค์ œQPS: %s, avg=%sms, p50=%sms, p95=%sms, p99=%sms, ์ด์‹œ๊ฐ„=%dms%s", + dataLabel, ucLabel, + sorted.length, totalRequests, errors.get(), + String.format("%.1f", actualQps), + String.format("%.2f", avg(sorted)), + String.format("%.2f", percentile(sorted, 50)), + String.format("%.2f", percentile(sorted, 95)), + String.format("%.2f", percentile(sorted, 99)), + totalTimeMs, + finished ? "" : " [TIMEOUT โ€” ์ผ๋ถ€ ๋ฏธ์™„๋ฃŒ]")); + } else { + out(String.format("[%s] [API ์ง€์†๋ถ€ํ•˜] %s โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: %d)", dataLabel, ucLabel, errors.get())); + } + } + + + private long measureRequestLatency(String url) throws Exception { + long start = System.nanoTime(); + executeSuccessfulRequest(url); + return System.nanoTime() - start; + } + + + private void executeSuccessfulRequest(String url) throws Exception { + MvcResult result = mockMvc.perform(get(url)).andReturn(); + int status = result.getResponse().getStatus(); + if (status < 200 || status >= 300) { + throw new IllegalStateException("Unexpected status: " + status + " for url=" + url); + } + } + + + // --- ํŒŒ์ผ ์ถœ๋ ฅ ์œ ํ‹ธ --- + + private void out(String msg) { + log.info(msg); + try (PrintWriter pw = new PrintWriter(new FileWriter(RESULT_FILE, true))) { + pw.println(msg); + pw.flush(); + } catch (IOException e) { + // ๋ฌด์‹œ + } + } + + + // --- ํ†ต๊ณ„ ์œ ํ‹ธ --- + + private double avg(long[] sorted) { + long sum = 0; + for (long v : sorted) sum += v; + return sum / (double) sorted.length / 1_000_000.0; + } + + private double percentile(long[] sorted, double p) { + int index = (int) Math.ceil(p / 100.0 * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))] / 1_000_000.0; + } + + + // --- ๋ฐ์ดํ„ฐ ์‚ฝ์ž… --- + + // SQL batch INSERT ๋ฐฐ์น˜ ํฌ๊ธฐ (ํ•œ INSERT ๋ฌธ์— ํฌํ•จ๋˜๋Š” VALUES ํ–‰ ์ˆ˜) + private static final int SQL_BATCH_SIZE = 2_000; + // ์ปค๋ฐ‹ ๊ฐ„๊ฒฉ (redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€) + private static final int COMMIT_INTERVAL = 10_000; + + /** + * SQL multi-row INSERT๋กœ ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + * - products ํ…Œ์ด๋ธ”์€ ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์—†์Œ (AS-IS) โ†’ ์ธ๋ฑ์Šค ๊ด€๋ฆฌ ๋ถˆํ•„์š” + * - multi-row INSERT: ํ•œ ๋ฌธ์žฅ์— 2000ํ–‰์”ฉ ๋ฌถ์–ด ๋„คํŠธ์›Œํฌ ๋ผ์šด๋“œํŠธ๋ฆฝ ์ตœ์†Œํ™” + * - 10,000ํ–‰๋งˆ๋‹ค COMMIT: MySQL redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€ + */ + private void insertBulkData(int productCount, int batchSize) throws Exception { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + + // 1. ๋ธŒ๋žœ๋“œ 50๊ฐœ (์†Œ๋Ÿ‰์ด๋ฏ€๋กœ ๋‹จ์ผ multi-row INSERT) + try (Statement stmt = conn.createStatement()) { + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO brands (name, description, visible_status, created_at, updated_at) VALUES "); + Timestamp now = Timestamp.from(Instant.now()); + String nowStr = now.toString(); + for (int i = 1; i <= BRAND_COUNT; i++) { + if (i > 1) sb.append(','); + sb.append("('Brand_").append(i).append("','Brand description ").append(i) + .append("','VISIBLE','").append(nowStr).append("','").append(nowStr).append("')"); + } + stmt.executeUpdate(sb.toString()); + conn.commit(); + } + + // 2. ์ƒํ’ˆ N๊ฑด โ€” multi-row INSERT (products๋Š” ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์—†์Œ) + try (Statement stmt = conn.createStatement()) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + Instant baseTime = Instant.now(); + int totalInserted = 0; + + for (int offset = 0; offset < productCount; offset += SQL_BATCH_SIZE) { + int end = Math.min(offset + SQL_BATCH_SIZE, productCount); + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO products (brand_id, name, price, stock, description, created_at, updated_at) VALUES "); + + for (int i = offset; i < end; i++) { + if (i > offset) sb.append(','); + long brandId = rng.nextLong(1, BRAND_COUNT + 1); + long price = rng.nextLong(1_000, 100_001); + long stock = rng.nextLong(0, 1_001); + Instant createdAt = baseTime.minus(rng.nextLong(0, 365), ChronoUnit.DAYS); + String ts = Timestamp.from(createdAt).toString(); + + sb.append('(') + .append(brandId).append(",'Product_").append(i) + .append("',").append(price) + .append(',').append(stock) + .append(",'Description for product ").append(i) + .append("','").append(ts).append("','").append(ts).append("')"); + } + + stmt.executeUpdate(sb.toString()); + totalInserted += (end - offset); + + // redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€: COMMIT_INTERVAL๋งˆ๋‹ค ์ปค๋ฐ‹ + if (totalInserted % COMMIT_INTERVAL == 0) { + conn.commit(); + } + + if (totalInserted % 100_000 == 0) { + log.info(" [INSERT] {}๊ฑด ์™„๋ฃŒ...", totalInserted); + } + } + + // ์ž”์—ฌ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ + conn.commit(); + } + } + } + + + /** + * products + brands ์กฐ์ธ ๊ฒฐ๊ณผ๋ฅผ product_read_model ํ…Œ์ด๋ธ”์— ์‚ฝ์ž… + * - DROP INDEX โ†’ INSERT โ†’ CREATE INDEX (InnoDB ์ตœ์  ํŒจํ„ด) + * - DISABLE/ENABLE KEYS๋Š” MyISAM ์ „์šฉ์ด๋ฏ€๋กœ InnoDB์—์„œ๋Š” ํšจ๊ณผ ์—†์Œ + * - 12๊ฐœ ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค๋ฅผ ๋จผ์ € ์ œ๊ฑฐํ•˜๊ณ , ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ํ›„ ์ผ๊ด„ ์žฌ์ƒ์„ฑ + * - TO-BE API๋Š” product_read_model์„ ์กฐํšŒํ•˜๋ฏ€๋กœ ๋ฐ์ดํ„ฐ ์ค€๋น„ ํ•„์š” + */ + private void insertReadModelFromProducts() throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + // ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์ œ๊ฑฐ (InnoDB: INSERT ์ค‘ ์ธ๋ฑ์Šค ์œ ์ง€ ๋น„์šฉ ์ œ๊ฑฐ) + dropReadModelIndexes(stmt); + + // ๋ฐ์ดํ„ฐ ์‚ฝ์ž… (์ธ๋ฑ์Šค ์—†์ด ์ˆœ์ˆ˜ INSERT) + stmt.executeUpdate( + "INSERT INTO product_read_model (id, brand_id, brand_name, name, price, stock, description, like_count, created_at, updated_at) " + + "SELECT p.id, p.brand_id, b.name, p.name, p.price, p.stock, p.description, FLOOR(RAND() * 10001), p.created_at, p.updated_at " + + "FROM products p LEFT JOIN brands b ON b.id = p.brand_id" + ); + + // ์ธ๋ฑ์Šค ์ผ๊ด„ ์žฌ์ƒ์„ฑ (๋‹จ์ผ ํŒจ์Šค๋กœ B-tree ๊ตฌ์ถ•) + createReadModelIndexes(stmt); + } + } + + + /** + * product_read_model ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค 12๊ฐœ ์ œ๊ฑฐ + * - PK๋Š” ์œ ์ง€ (InnoDB clustered index) + */ + private void dropReadModelIndexes(Statement stmt) throws SQLException { + String[] indexes = { + "idx_read_brand_deleted_created", "idx_read_brand_deleted_price", "idx_read_brand_deleted_likecount", + "idx_read_deleted_created", "idx_read_deleted_price", "idx_read_deleted_likecount", + "idx_read_brand_created", "idx_read_brand_price", "idx_read_brand_likecount", + "idx_read_created", "idx_read_price", "idx_read_likecount" + }; + for (String idx : indexes) { + stmt.execute("DROP INDEX " + idx + " ON product_read_model"); + } + } + + + /** + * product_read_model ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค 12๊ฐœ ์žฌ์ƒ์„ฑ + * - ProductReadModelEntity @Table(indexes) ์ •์˜์™€ ๋™์ผ + */ + private void createReadModelIndexes(Statement stmt) throws SQLException { + String[] ddls = { + // ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? AND deleted_at IS NULL ORDER BY {sort_col} + "CREATE INDEX idx_read_brand_deleted_created ON product_read_model (brand_id, deleted_at, created_at)", + "CREATE INDEX idx_read_brand_deleted_price ON product_read_model (brand_id, deleted_at, price)", + "CREATE INDEX idx_read_brand_deleted_likecount ON product_read_model (brand_id, deleted_at, like_count)", + // ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ๋ฏธ์ง€์ •): WHERE deleted_at IS NULL ORDER BY {sort_col} + "CREATE INDEX idx_read_deleted_created ON product_read_model (deleted_at, created_at)", + "CREATE INDEX idx_read_deleted_price ON product_read_model (deleted_at, price)", + "CREATE INDEX idx_read_deleted_likecount ON product_read_model (deleted_at, like_count)", + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? ORDER BY {sort_col} + "CREATE INDEX idx_read_brand_created ON product_read_model (brand_id, created_at)", + "CREATE INDEX idx_read_brand_price ON product_read_model (brand_id, price)", + "CREATE INDEX idx_read_brand_likecount ON product_read_model (brand_id, like_count)", + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (ํ•„ํ„ฐ ์—†์Œ): ORDER BY {sort_col} + "CREATE INDEX idx_read_created ON product_read_model (created_at)", + "CREATE INDEX idx_read_price ON product_read_model (price)", + "CREATE INDEX idx_read_likecount ON product_read_model (like_count)" + }; + for (String ddl : ddls) { + stmt.execute(ddl); + } + } + +} diff --git a/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductIndexPerformanceTest.java b/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductIndexPerformanceTest.java new file mode 100644 index 000000000..a2fc12a52 --- /dev/null +++ b/apps/commerce-api/src/benchmark/java/com/loopers/catalog/product/infrastructure/ProductIndexPerformanceTest.java @@ -0,0 +1,941 @@ +package com.loopers.catalog.product.infrastructure; + + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import javax.sql.DataSource; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.*; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + + +/** + * ์ƒํ’ˆ ์ธ๋ฑ์Šค ์„ฑ๋Šฅ ์ธก์ • (AS-IS / TO-BE) + * + *

AS-IS: products ํ…Œ์ด๋ธ” (PK ์™ธ ์ธ๋ฑ์Šค ์—†์Œ) + LEFT JOIN brands + LEFT JOIN likes (LIKES_DESC) + *

TO-BE: product_read_model ํ…Œ์ด๋ธ” (๋ณตํ•ฉ ์ธ๋ฑ์Šค) + ๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT (JOIN ์ œ๊ฑฐ, like_count ๋น„์ •๊ทœํ™”) + * + *

์ธก์ • ์ถ•: ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ(10๋งŒ/100๋งŒ/1000๋งŒ) ร— ํŠธ๋ž˜ํ”ฝ ์œ ํ˜•(๋‹จ์ผ์ฟผ๋ฆฌ/๋ฒ„์ŠคํŠธ/์ง€์†๋ถ€ํ•˜) + * + *

1. ๋‹จ์ผ ์ฟผ๋ฆฌ: 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค EXPLAIN + ์‹คํ–‰์‹œ๊ฐ„ (warmup 3ํšŒ + ์ธก์ • 5ํšŒ ํ‰๊ท ) + *

2. ๋ฒ„์ŠคํŠธ: 100 concurrent ๋™์‹œ ์š”์ฒญ โ†’ p50/p95/p99 + *

3. ์ง€์† ๋ถ€ํ•˜: 20 RPS ร— 10์ดˆ โ†’ ์ฒ˜๋ฆฌ๋Ÿ‰ + p50/p95/p99 + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@DisplayName("์ƒํ’ˆ ์ธ๋ฑ์Šค ์„ฑ๋Šฅ ์ธก์ •") +class ProductIndexPerformanceTest { + + private static final Logger log = LoggerFactory.getLogger(ProductIndexPerformanceTest.class); + private static final String RESULT_FILE = "/tmp/index-perf-results.txt"; + private static final int BRAND_COUNT = 50; + + // ๋ฒ„์ŠคํŠธ ํŒŒ๋ผ๋ฏธํ„ฐ + private static final int BURST_THREADS = 100; + + // ์ง€์† ๋ถ€ํ•˜ ํŒŒ๋ผ๋ฏธํ„ฐ + private static final int SUSTAINED_RPS = 20; + private static final int SUSTAINED_DURATION_SEC = 10; + + @Autowired + private DataSource dataSource; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + + // ๋กœ๊ทธ + ํŒŒ์ผ ๋™์‹œ ์ถœ๋ ฅ (Gradle ๋ฒ„ํผ๋ง ์šฐํšŒ) + private void out(String msg) { + log.info(msg); + try (PrintWriter pw = new PrintWriter(new FileWriter(RESULT_FILE, true))) { + pw.println(msg); + pw.flush(); + } catch (IOException e) { + // ๋ฌด์‹œ + } + } + + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + + // --- ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ (๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„) --- + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + @DisplayName("[AS-IS] 10๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (๋‹จ์ผ์ฟผ๋ฆฌ + ๋ฒ„์ŠคํŠธ + ์ง€์†๋ถ€ํ•˜)") + void measureAsIs_100K() throws Exception { + runMeasurement(100_000, "100K", 1_000); + } + + + @Test + @Timeout(value = 15, unit = TimeUnit.MINUTES) + @DisplayName("[AS-IS] 100๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (๋‹จ์ผ์ฟผ๋ฆฌ + ๋ฒ„์ŠคํŠธ + ์ง€์†๋ถ€ํ•˜)") + void measureAsIs_1M() throws Exception { + runMeasurement(1_000_000, "1M", 5_000); + } + + + @Test + @Timeout(value = 30, unit = TimeUnit.MINUTES) + @DisplayName("[AS-IS] 1000๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (๋‹จ์ผ์ฟผ๋ฆฌ + ๋ฒ„์ŠคํŠธ + ์ง€์†๋ถ€ํ•˜)") + void measureAsIs_10M() throws Exception { + runMeasurement(10_000_000, "10M", 10_000); + } + + + // --- TO-BE ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค) --- + + @Test + @Timeout(value = 5, unit = TimeUnit.MINUTES) + @DisplayName("[TO-BE] 10๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค)") + void measureToBe_100K() throws Exception { + runToMeasurement(100_000, "100K", 1_000); + } + + + @Test + @Timeout(value = 15, unit = TimeUnit.MINUTES) + @DisplayName("[TO-BE] 100๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค)") + void measureToBe_1M() throws Exception { + runToMeasurement(1_000_000, "1M", 5_000); + } + + + @Test + @Timeout(value = 30, unit = TimeUnit.MINUTES) + @DisplayName("[TO-BE] 1000๋งŒ๊ฑด ์„ฑ๋Šฅ ์ธก์ • (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค)") + void measureToBe_10M() throws Exception { + runToMeasurement(10_000_000, "10M", 10_000); + } + + + // --- ํ•ต์‹ฌ ์ธก์ • ํ๋ฆ„ --- + + private void runMeasurement(int productCount, String label, int batchSize) throws Exception { + log.info("\n========================================"); + log.info("[{}] AS-IS ์„ฑ๋Šฅ ์ธก์ • ์‹œ์ž‘ (๋ธŒ๋žœ๋“œ {}๊ฐœ, ์ƒํ’ˆ {}๊ฑด)", label, BRAND_COUNT, productCount); + log.info("========================================"); + + // 1. ๋ฐ์ดํ„ฐ ์ค€๋น„ (brands + products + product_likes) + long insertStart = System.currentTimeMillis(); + insertBulkData(productCount, batchSize); + insertBulkLikes(productCount); + long insertElapsed = System.currentTimeMillis() - insertStart; + log.info("[{}] ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์™„๋ฃŒ: {}ms", label, insertElapsed); + + // ANALYZE TABLE๋กœ MySQL ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ (EXPLAIN ์ •ํ™•๋„ ํ–ฅ์ƒ) + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("ANALYZE TABLE products"); + stmt.execute("ANALYZE TABLE brands"); + stmt.execute("ANALYZE TABLE likes"); + } + + // 2. ์ธ๋ฑ์Šค ํ˜„ํ™ฉ + log.info("\n[{}] ----- ํ˜„์žฌ ์ธ๋ฑ์Šค -----", label); + logIndexes("products"); + + // 3. ๋ฐ์ดํ„ฐ ๋ถ„ํฌ + log.info("\n[{}] ----- ๋ฐ์ดํ„ฐ ๋ถ„ํฌ -----", label); + logDataDistribution(); + + // 4. ์ฟผ๋ฆฌ ์ •์˜ + String baseSelect = "SELECT p.id, p.brand_id, b.name AS brand_name, p.name, p.price, p.stock " + + "FROM products p LEFT JOIN brands b ON b.id = p.brand_id "; + + // AS-IS LIKES_DESC: Read Model ๋„์ž… ์ „ ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ์€ likes ํ…Œ์ด๋ธ”์„ JOIN + GROUP BY + COUNTํ•ด์•ผ ํ•จ + String likesSelect = "SELECT p.id, p.brand_id, b.name AS brand_name, p.name, p.price, p.stock, COUNT(l.id) AS like_count " + + "FROM products p LEFT JOIN brands b ON b.id = p.brand_id " + + "LEFT JOIN likes l ON l.target_type = 'PRODUCT' AND l.target_id = p.id "; + + String[][] queries = { + {"UC1: brandId=X, LATEST", baseSelect + "WHERE p.deleted_at IS NULL ORDER BY p.created_at DESC LIMIT 20"}, + {"UC2: brandId=X, PRICE_ASC", baseSelect + "WHERE p.deleted_at IS NULL ORDER BY p.price ASC LIMIT 20"}, + {"UC3: brandId=X, LIKES_DESC", likesSelect + "WHERE p.deleted_at IS NULL GROUP BY p.id ORDER BY like_count DESC LIMIT 20"}, + {"UC4: brandId=1, LATEST", baseSelect + "WHERE p.deleted_at IS NULL AND p.brand_id = 1 ORDER BY p.created_at DESC LIMIT 20"}, + {"UC5: brandId=1, PRICE_ASC", baseSelect + "WHERE p.deleted_at IS NULL AND p.brand_id = 1 ORDER BY p.price ASC LIMIT 20"}, + {"UC6: brandId=1, LIKES_DESC", likesSelect + "WHERE p.deleted_at IS NULL AND p.brand_id = 1 GROUP BY p.id ORDER BY like_count DESC LIMIT 20"}, + }; + String[][] countQueries = { + {"COUNT: brandId=X", "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL"}, + {"COUNT: brandId=1", "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND brand_id = 1"}, + }; + + // 5. ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • (EXPLAIN + ์‹คํ–‰์‹œ๊ฐ„) + log.info("\n[{}] ===== ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • =====", label); + for (String[] q : queries) { + measureSingleQuery(label, q[0], q[1]); + } + for (String[] q : countQueries) { + measureSingleQuery(label, q[0], q[1]); + } + + // 6. ๋ฒ„์ŠคํŠธ ์ธก์ • (๋Œ€ํ‘œ UC: UC1 no-brand latest, UC3 no-brand likes, UC4 brand latest) + log.info("\n[{}] ===== ๋ฒ„์ŠคํŠธ ์ธก์ • ({}๊ฑด ๋™์‹œ) =====", label, BURST_THREADS); + measureBurst(label, queries[0][0], queries[0][1]); + measureBurst(label, queries[2][0], queries[2][1]); + measureBurst(label, queries[3][0], queries[3][1]); + + // 7. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (๋Œ€ํ‘œ UC) + log.info("\n[{}] ===== ์ง€์† ๋ถ€ํ•˜ ์ธก์ • ({} RPS ร— {}์ดˆ) =====", label, SUSTAINED_RPS, SUSTAINED_DURATION_SEC); + measureSustainedLoad(label, queries[0][0], queries[0][1]); + measureSustainedLoad(label, queries[2][0], queries[2][1]); + measureSustainedLoad(label, queries[3][0], queries[3][1]); + + log.info("\n[{}] ===== AS-IS ์ธก์ • ์™„๋ฃŒ =====", label); + } + + + /** + * TO-BE ํ•ต์‹ฌ ์ธก์ • ํ๋ฆ„ + * - product_read_model ํ…Œ์ด๋ธ” ์ƒ์„ฑ (๋ณตํ•ฉ ์ธ๋ฑ์Šค ํฌํ•จ) + * - brands + products + product_read_model ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + * - ๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT (JOIN ์—†์Œ) 6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค ์ธก์ • + */ + private void runToMeasurement(int productCount, String label, int batchSize) throws Exception { + // ๊ฒฐ๊ณผ ํŒŒ์ผ ์ดˆ๊ธฐํ™” + try (PrintWriter pw = new PrintWriter(new FileWriter(RESULT_FILE, false))) { + pw.println("=== TO-BE Index Performance Results ==="); + } + + out("\n========================================"); + out(String.format("[%s] TO-BE ์„ฑ๋Šฅ ์ธก์ • ์‹œ์ž‘ (๋ธŒ๋žœ๋“œ %d๊ฐœ, ์ƒํ’ˆ %d๊ฑด, Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค)", label, BRAND_COUNT, productCount)); + out("========================================"); + + // 1. ๋ฐ์ดํ„ฐ ์ค€๋น„ (brands + products + product_read_model) + long insertStart = System.currentTimeMillis(); + insertBulkDataWithReadModel(productCount, batchSize); + long insertElapsed = System.currentTimeMillis() - insertStart; + out(String.format("[%s] TO-BE ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ์™„๋ฃŒ: %dms", label, insertElapsed)); + + // ANALYZE TABLE๋กœ MySQL ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ (EXPLAIN ์ •ํ™•๋„ ํ–ฅ์ƒ) + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("ANALYZE TABLE products"); + stmt.execute("ANALYZE TABLE brands"); + stmt.execute("ANALYZE TABLE product_read_model"); + } + + // 2. ์ธ๋ฑ์Šค ํ˜„ํ™ฉ + out(String.format("\n[%s] ----- TO-BE ํ˜„์žฌ ์ธ๋ฑ์Šค (product_read_model) -----", label)); + logIndexesToFile("product_read_model"); + + // 3. ๋ฐ์ดํ„ฐ ๋ถ„ํฌ + out(String.format("\n[%s] ----- TO-BE ๋ฐ์ดํ„ฐ ๋ถ„ํฌ (product_read_model) -----", label)); + logReadModelDataDistributionToFile(); + + // 4. ์ฟผ๋ฆฌ ์ •์˜ (๋‹จ์ผ ํ…Œ์ด๋ธ”, JOIN ์—†์Œ) + String baseSelect = "SELECT id, brand_id, brand_name, name, price, stock, like_count " + + "FROM product_read_model "; + + String[][] queries = { + {"UC1: brandId=X, LATEST", baseSelect + "WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20"}, + {"UC2: brandId=X, PRICE_ASC", baseSelect + "WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20"}, + {"UC3: brandId=X, LIKES_DESC", baseSelect + "WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20"}, + {"UC4: brandId=1, LATEST", baseSelect + "WHERE deleted_at IS NULL AND brand_id = 1 ORDER BY created_at DESC LIMIT 20"}, + {"UC5: brandId=1, PRICE_ASC", baseSelect + "WHERE deleted_at IS NULL AND brand_id = 1 ORDER BY price ASC LIMIT 20"}, + {"UC6: brandId=1, LIKES_DESC", baseSelect + "WHERE deleted_at IS NULL AND brand_id = 1 ORDER BY like_count DESC LIMIT 20"}, + }; + String[][] countQueries = { + {"COUNT: brandId=X", "SELECT COUNT(*) FROM product_read_model WHERE deleted_at IS NULL"}, + {"COUNT: brandId=1", "SELECT COUNT(*) FROM product_read_model WHERE deleted_at IS NULL AND brand_id = 1"}, + }; + + // 5. ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • (EXPLAIN + ์‹คํ–‰์‹œ๊ฐ„) + out(String.format("\n[%s] ===== TO-BE ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • =====", label)); + for (String[] q : queries) { + measureSingleQueryToFile(label, q[0], q[1]); + } + for (String[] q : countQueries) { + measureSingleQueryToFile(label, q[0], q[1]); + } + + // 6. ๋ฒ„์ŠคํŠธ ์ธก์ • (๋Œ€ํ‘œ UC: UC1 no-brand latest, UC3 no-brand likes, UC4 brand latest) + out(String.format("\n[%s] ===== TO-BE ๋ฒ„์ŠคํŠธ ์ธก์ • (%d๊ฑด ๋™์‹œ) =====", label, BURST_THREADS)); + measureBurstToFile(label, queries[0][0], queries[0][1]); + measureBurstToFile(label, queries[2][0], queries[2][1]); + measureBurstToFile(label, queries[3][0], queries[3][1]); + + // 7. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (๋Œ€ํ‘œ UC) + out(String.format("\n[%s] ===== TO-BE ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (%d RPS ร— %d์ดˆ) =====", label, SUSTAINED_RPS, SUSTAINED_DURATION_SEC)); + measureSustainedLoadToFile(label, queries[0][0], queries[0][1]); + measureSustainedLoadToFile(label, queries[2][0], queries[2][1]); + measureSustainedLoadToFile(label, queries[3][0], queries[3][1]); + + out(String.format("\n[%s] ===== TO-BE ์ธก์ • ์™„๋ฃŒ =====", label)); + } + + + // --- ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • --- + + private void measureSingleQuery(String dataLabel, String ucLabel, String sql) throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + // EXPLAIN + try (ResultSet rs = stmt.executeQuery("EXPLAIN " + sql)) { + while (rs.next()) { + log.info("[{}] [EXPLAIN] {} โ€” table={}, type={}, key={}, rows={}, filtered={}, Extra={}", + dataLabel, ucLabel, + rs.getString("table"), + rs.getString("type"), + rs.getString("key"), + rs.getString("rows"), + rs.getString("filtered"), + rs.getString("Extra")); + } + } + + // Warmup (3ํšŒ) โ€” ๋ฒ„ํผ ํ’€/JIT/์ปค๋„ฅ์…˜ ํ’€ ์•ˆ์ •ํ™” + for (int w = 0; w < 3; w++) { + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + } + } + } + + // ์‹คํ–‰์‹œ๊ฐ„ ์ธก์ • (5ํšŒ) + int runs = 5; + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + long start = System.nanoTime(); + try (ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + } + } + times[i] = System.nanoTime() - start; + } + + long sum = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long t : times) { + sum += t; + min = Math.min(min, t); + max = Math.max(max, t); + } + + log.info("[{}] [๋‹จ์ผ์ฟผ๋ฆฌ] {} โ€” avg={}ms, min={}ms, max={}ms", + dataLabel, ucLabel, + String.format("%.2f", sum / (double) runs / 1_000_000.0), + String.format("%.2f", min / 1_000_000.0), + String.format("%.2f", max / 1_000_000.0)); + } + } + + + // --- ๋ฒ„์ŠคํŠธ ์ธก์ • (N๊ฐœ ๋™์‹œ ์š”์ฒญ) --- + + private void measureBurst(String dataLabel, String ucLabel, String sql) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(BURST_THREADS); + CountDownLatch ready = new CountDownLatch(BURST_THREADS); + CountDownLatch go = new CountDownLatch(1); + long[] latencies = new long[BURST_THREADS]; + AtomicInteger errors = new AtomicInteger(0); + + // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์ค€๋น„ ํ›„ ์ผ์ œํžˆ ์‹œ์ž‘ + for (int i = 0; i < BURST_THREADS; i++) { + final int idx = i; + executor.submit(() -> { + ready.countDown(); + try { + go.await(); + long start = System.nanoTime(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + } + } + latencies[idx] = System.nanoTime() - start; + } catch (Exception e) { + errors.incrementAndGet(); + latencies[idx] = -1; + } + }); + } + + ready.await(); + go.countDown(); + + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.MINUTES); + + // ์—๋Ÿฌ ์ œ์™ธ, ์ •๋ ฌ + long[] valid = Arrays.stream(latencies).filter(l -> l > 0).sorted().toArray(); + if (valid.length > 0) { + log.info("[{}] [๋ฒ„์ŠคํŠธ] {} โ€” ์™„๋ฃŒ: {}/{}, ์—๋Ÿฌ: {}, avg={}ms, p50={}ms, p95={}ms, p99={}ms, max={}ms", + dataLabel, ucLabel, + valid.length, BURST_THREADS, errors.get(), + String.format("%.2f", avg(valid)), + String.format("%.2f", percentile(valid, 50)), + String.format("%.2f", percentile(valid, 95)), + String.format("%.2f", percentile(valid, 99)), + String.format("%.2f", valid[valid.length - 1] / 1_000_000.0)); + } else { + log.warn("[{}] [๋ฒ„์ŠคํŠธ] {} โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: {})", dataLabel, ucLabel, errors.get()); + } + } + + + // --- ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (N RPS ร— T์ดˆ) --- + + private void measureSustainedLoad(String dataLabel, String ucLabel, String sql) throws Exception { + int totalRequests = SUSTAINED_RPS * SUSTAINED_DURATION_SEC; + long intervalMs = 1000 / SUSTAINED_RPS; + + ExecutorService executor = Executors.newFixedThreadPool(SUSTAINED_RPS * 2); + List latencies = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger errors = new AtomicInteger(0); + + long testStart = System.nanoTime(); + + // ์ผ์ • ๊ฐ„๊ฒฉ์œผ๋กœ ์š”์ฒญ ์ œ์ถœ + for (int i = 0; i < totalRequests; i++) { + executor.submit(() -> { + long start = System.nanoTime(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + } + latencies.add(System.nanoTime() - start); + } catch (Exception e) { + errors.incrementAndGet(); + } + }); + + // Rate limiting + long elapsed = (System.nanoTime() - testStart) / 1_000_000; + long expected = (long) (i + 1) * intervalMs; + long sleepMs = expected - elapsed; + if (sleepMs > 0) { + Thread.sleep(sleepMs); + } + } + + executor.shutdown(); + boolean finished = executor.awaitTermination(5, TimeUnit.MINUTES); + + long totalTimeMs = (System.nanoTime() - testStart) / 1_000_000; + + // ๊ฒฐ๊ณผ ์ง‘๊ณ„ + long[] sorted = latencies.stream().mapToLong(l -> l).sorted().toArray(); + if (sorted.length > 0) { + double actualQps = (double) sorted.length / totalTimeMs * 1000; + log.info("[{}] [์ง€์†๋ถ€ํ•˜] {} โ€” ์™„๋ฃŒ: {}/{}, ์—๋Ÿฌ: {}, ์‹ค์ œQPS: {}, avg={}ms, p50={}ms, p95={}ms, p99={}ms, ์ด์‹œ๊ฐ„={}ms{}", + dataLabel, ucLabel, + sorted.length, totalRequests, errors.get(), + String.format("%.1f", actualQps), + String.format("%.2f", avg(sorted)), + String.format("%.2f", percentile(sorted, 50)), + String.format("%.2f", percentile(sorted, 95)), + String.format("%.2f", percentile(sorted, 99)), + totalTimeMs, + finished ? "" : " [TIMEOUT โ€” ์ผ๋ถ€ ๋ฏธ์™„๋ฃŒ]"); + } else { + log.warn("[{}] [์ง€์†๋ถ€ํ•˜] {} โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: {})", dataLabel, ucLabel, errors.get()); + } + } + + + // --- ํ†ต๊ณ„ ์œ ํ‹ธ --- + + private double avg(long[] sorted) { + long sum = 0; + for (long v : sorted) sum += v; + return sum / (double) sorted.length / 1_000_000.0; + } + + private double percentile(long[] sorted, double p) { + int index = (int) Math.ceil(p / 100.0 * sorted.length) - 1; + return sorted[Math.max(0, Math.min(index, sorted.length - 1))] / 1_000_000.0; + } + + + // --- ๋ฐ์ดํ„ฐ ์‚ฝ์ž… --- + + // SQL batch INSERT ๋ฐฐ์น˜ ํฌ๊ธฐ (ํ•œ INSERT ๋ฌธ์— ํฌํ•จ๋˜๋Š” VALUES ํ–‰ ์ˆ˜) + private static final int SQL_BATCH_SIZE = 2_000; + // ์ปค๋ฐ‹ ๊ฐ„๊ฒฉ (redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€) + private static final int COMMIT_INTERVAL = 10_000; + + /** + * SQL multi-row INSERT๋กœ ๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ ์‚ฝ์ž… + * - products ํ…Œ์ด๋ธ”์€ ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์—†์Œ (AS-IS) โ†’ ์ธ๋ฑ์Šค ๊ด€๋ฆฌ ๋ถˆํ•„์š” + * - multi-row INSERT: ํ•œ ๋ฌธ์žฅ์— 2000ํ–‰์”ฉ ๋ฌถ์–ด ๋„คํŠธ์›Œํฌ ๋ผ์šด๋“œํŠธ๋ฆฝ ์ตœ์†Œํ™” + * - 10,000ํ–‰๋งˆ๋‹ค COMMIT: MySQL redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€ + */ + private void insertBulkData(int productCount, int batchSize) throws Exception { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + + // 1. ๋ธŒ๋žœ๋“œ 50๊ฐœ (์†Œ๋Ÿ‰์ด๋ฏ€๋กœ ๋‹จ์ผ multi-row INSERT) + try (Statement stmt = conn.createStatement()) { + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO brands (name, description, visible_status, created_at, updated_at) VALUES "); + Timestamp now = Timestamp.from(Instant.now()); + String nowStr = now.toString(); + for (int i = 1; i <= BRAND_COUNT; i++) { + if (i > 1) sb.append(','); + sb.append("('Brand_").append(i).append("','Brand description ").append(i) + .append("','VISIBLE','").append(nowStr).append("','").append(nowStr).append("')"); + } + stmt.executeUpdate(sb.toString()); + conn.commit(); + } + + // 2. ์ƒํ’ˆ N๊ฑด โ€” multi-row INSERT (products๋Š” ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์—†์Œ) + try (Statement stmt = conn.createStatement()) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + Instant baseTime = Instant.now(); + int totalInserted = 0; + + for (int offset = 0; offset < productCount; offset += SQL_BATCH_SIZE) { + int end = Math.min(offset + SQL_BATCH_SIZE, productCount); + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO products (brand_id, name, price, stock, description, created_at, updated_at) VALUES "); + + for (int i = offset; i < end; i++) { + if (i > offset) sb.append(','); + long brandId = rng.nextLong(1, BRAND_COUNT + 1); + long price = rng.nextLong(1_000, 100_001); + long stock = rng.nextLong(0, 1_001); + Instant createdAt = baseTime.minus(rng.nextLong(0, 365), ChronoUnit.DAYS); + String ts = Timestamp.from(createdAt).toString(); + + sb.append('(') + .append(brandId).append(",'Product_").append(i) + .append("',").append(price) + .append(',').append(stock) + .append(",'Description for product ").append(i) + .append("','").append(ts).append("','").append(ts).append("')"); + } + + stmt.executeUpdate(sb.toString()); + totalInserted += (end - offset); + + // redo log ๊ณ ๊ฐˆ ๋ฐฉ์ง€: COMMIT_INTERVAL๋งˆ๋‹ค ์ปค๋ฐ‹ + if (totalInserted % COMMIT_INTERVAL == 0) { + conn.commit(); + } + + if (totalInserted % 100_000 == 0) { + log.info(" [INSERT] {}๊ฑด ์™„๋ฃŒ...", totalInserted); + } + } + + // ์ž”์—ฌ ํŠธ๋žœ์žญ์…˜ ์ปค๋ฐ‹ + conn.commit(); + } + } + } + + + /** + * AS-IS ์ข‹์•„์š” ๋ฐ์ดํ„ฐ ์‚ฝ์ž… (likes ํ…Œ์ด๋ธ”) + * - Read Model ๋„์ž… ์ „ ์ข‹์•„์š” ์ˆœ ์ •๋ ฌ์€ likes ํ…Œ์ด๋ธ”์„ JOIN + GROUP BY + COUNTํ•ด์•ผ ํ•จ + * - ์ƒํ’ˆ๋‹น 0~100๊ฑด ๋žœ๋ค ์ข‹์•„์š” ์ƒ์„ฑ (user_id๋Š” 1~10000 ๋ฒ”์œ„) + * - multi-row INSERT + COMMIT_INTERVAL ๋‹จ์œ„ ์ปค๋ฐ‹ + */ + private void insertBulkLikes(int productCount) throws Exception { + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + + try (Statement stmt = conn.createStatement()) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + Timestamp now = Timestamp.from(Instant.now()); + String nowStr = now.toString(); + int totalInserted = 0; + + for (int productId = 1; productId <= productCount; productId++) { + // ์ƒํ’ˆ๋‹น 0~100๊ฑด ๋žœ๋ค ์ข‹์•„์š” (user_id๋Š” 1๋ถ€ํ„ฐ ์ˆœ์ฐจ โ€” UNIQUE ์ œ์•ฝ ํšŒํ”ผ) + int likeCount = rng.nextInt(0, 101); + if (likeCount == 0) { + continue; + } + + // multi-row INSERT (SQL_BATCH_SIZE ๋‹จ์œ„) + for (int offset = 0; offset < likeCount; offset += SQL_BATCH_SIZE) { + int end = Math.min(offset + SQL_BATCH_SIZE, likeCount); + StringBuilder sb = new StringBuilder(); + sb.append("INSERT INTO likes (user_id, target_type, target_id, created_at, updated_at) VALUES "); + + for (int i = offset; i < end; i++) { + if (i > offset) sb.append(','); + // user_id = offset + i + 1 (์ƒํ’ˆ๋ณ„ ์ˆœ์ฐจ ํ• ๋‹น์œผ๋กœ UNIQUE(user_id, target_type, target_id) ๋ณด์žฅ) + long userId = i + 1; + sb.append('(') + .append(userId).append(",'PRODUCT',") + .append(productId) + .append(",'").append(nowStr).append("','").append(nowStr).append("')"); + } + + stmt.executeUpdate(sb.toString()); + totalInserted += (end - offset); + + if (totalInserted % COMMIT_INTERVAL == 0) { + conn.commit(); + } + } + + if (productId % 100_000 == 0) { + log.info(" [INSERT likes] ์ƒํ’ˆ {}๊ฑด๊นŒ์ง€ ์ข‹์•„์š” ์‚ฝ์ž… ์™„๋ฃŒ...", productId); + } + } + + conn.commit(); + log.info(" [INSERT likes] ์ด {}๊ฑด ์ข‹์•„์š” ์‚ฝ์ž… ์™„๋ฃŒ", totalInserted); + } + } + } + + + /** + * TO-BE ๋ฐ์ดํ„ฐ ์‚ฝ์ž…: brands + products + product_read_model (๋ณตํ•ฉ ์ธ๋ฑ์Šค ํฌํ•จ) + * - brands, products๋Š” ๊ธฐ์กด insertBulkData()์™€ ๋™์ผํ•˜๊ฒŒ ์‚ฝ์ž… + * - product_read_model์€ DROP INDEX โ†’ INSERT โ†’ CREATE INDEX (InnoDB ์ตœ์  ํŒจํ„ด) + * - DISABLE/ENABLE KEYS๋Š” MyISAM ์ „์šฉ์ด๋ฏ€๋กœ InnoDB์—์„œ๋Š” ํšจ๊ณผ ์—†์Œ + * - 12๊ฐœ ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค๋ฅผ ๋จผ์ € ์ œ๊ฑฐํ•˜๊ณ , ๋ฐ์ดํ„ฐ ์‚ฝ์ž… ํ›„ ์ผ๊ด„ ์žฌ์ƒ์„ฑํ•˜์—ฌ ์‚ฝ์ž… ์†๋„ ๊ทน๋Œ€ํ™” + */ + private void insertBulkDataWithReadModel(int productCount, int batchSize) throws Exception { + // 1. brands + products ์‚ฝ์ž… + insertBulkData(productCount, batchSize); + + // 2. product_read_model ํ…Œ์ด๋ธ”: DROP INDEX โ†’ INSERT โ†’ CREATE INDEX + // product_read_model ํ…Œ์ด๋ธ”์€ JPA auto-DDL๋กœ ์ด๋ฏธ ์ƒ์„ฑ๋จ (ProductReadModelEntity) + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + long start = System.currentTimeMillis(); + + // ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค ์ œ๊ฑฐ (InnoDB: INSERT ์ค‘ ์ธ๋ฑ์Šค ์œ ์ง€ ๋น„์šฉ ์ œ๊ฑฐ) + dropReadModelIndexes(stmt); + long dropElapsed = System.currentTimeMillis() - start; + out(String.format(" [DROP INDEX] product_read_model ์ธ๋ฑ์Šค ์ œ๊ฑฐ ์™„๋ฃŒ: %dms", dropElapsed)); + + // ๋ฐ์ดํ„ฐ ์‚ฝ์ž… (์ธ๋ฑ์Šค ์—†์ด ์ˆœ์ˆ˜ INSERT) + long insertStart = System.currentTimeMillis(); + stmt.executeUpdate( + "INSERT INTO product_read_model (id, brand_id, brand_name, name, price, stock, description, like_count, created_at, updated_at, deleted_at) " + + "SELECT p.id, p.brand_id, b.name, p.name, p.price, p.stock, p.description, FLOOR(RAND() * 10001), p.created_at, p.updated_at, p.deleted_at " + + "FROM products p LEFT JOIN brands b ON b.id = p.brand_id" + ); + long insertElapsed = System.currentTimeMillis() - insertStart; + out(String.format(" [INSERT] product_read_model ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ ์™„๋ฃŒ: %dms", insertElapsed)); + + // ์ธ๋ฑ์Šค ์ผ๊ด„ ์žฌ์ƒ์„ฑ (๋‹จ์ผ ํŒจ์Šค๋กœ B-tree ๊ตฌ์ถ•) + long createStart = System.currentTimeMillis(); + createReadModelIndexes(stmt); + long createElapsed = System.currentTimeMillis() - createStart; + out(String.format(" [CREATE INDEX] product_read_model ์ธ๋ฑ์Šค ์žฌ์ƒ์„ฑ ์™„๋ฃŒ: %dms", createElapsed)); + + long totalElapsed = System.currentTimeMillis() - start; + out(String.format(" [READ MODEL ์ด๊ณ„] DROP+INSERT+CREATE: %dms", totalElapsed)); + } + } + + + /** + * product_read_model ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค 12๊ฐœ ์ œ๊ฑฐ + * - PK๋Š” ์œ ์ง€ (InnoDB clustered index) + */ + private void dropReadModelIndexes(Statement stmt) throws SQLException { + String[] indexes = { + "idx_read_brand_deleted_created", "idx_read_brand_deleted_price", "idx_read_brand_deleted_likecount", + "idx_read_deleted_created", "idx_read_deleted_price", "idx_read_deleted_likecount", + "idx_read_brand_created", "idx_read_brand_price", "idx_read_brand_likecount", + "idx_read_created", "idx_read_price", "idx_read_likecount" + }; + for (String idx : indexes) { + stmt.execute("DROP INDEX " + idx + " ON product_read_model"); + } + } + + + /** + * product_read_model ์„ธ์ปจ๋”๋ฆฌ ์ธ๋ฑ์Šค 12๊ฐœ ์žฌ์ƒ์„ฑ + * - ProductReadModelEntity @Table(indexes) ์ •์˜์™€ ๋™์ผ + */ + private void createReadModelIndexes(Statement stmt) throws SQLException { + String[] ddls = { + // ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? AND deleted_at IS NULL ORDER BY {sort_col} + "CREATE INDEX idx_read_brand_deleted_created ON product_read_model (brand_id, deleted_at, created_at)", + "CREATE INDEX idx_read_brand_deleted_price ON product_read_model (brand_id, deleted_at, price)", + "CREATE INDEX idx_read_brand_deleted_likecount ON product_read_model (brand_id, deleted_at, like_count)", + // ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ๋ฏธ์ง€์ •): WHERE deleted_at IS NULL ORDER BY {sort_col} + "CREATE INDEX idx_read_deleted_created ON product_read_model (deleted_at, created_at)", + "CREATE INDEX idx_read_deleted_price ON product_read_model (deleted_at, price)", + "CREATE INDEX idx_read_deleted_likecount ON product_read_model (deleted_at, like_count)", + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? ORDER BY {sort_col} + "CREATE INDEX idx_read_brand_created ON product_read_model (brand_id, created_at)", + "CREATE INDEX idx_read_brand_price ON product_read_model (brand_id, price)", + "CREATE INDEX idx_read_brand_likecount ON product_read_model (brand_id, like_count)", + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (ํ•„ํ„ฐ ์—†์Œ): ORDER BY {sort_col} + "CREATE INDEX idx_read_created ON product_read_model (created_at)", + "CREATE INDEX idx_read_price ON product_read_model (price)", + "CREATE INDEX idx_read_likecount ON product_read_model (like_count)" + }; + for (String ddl : ddls) { + stmt.execute(ddl); + } + } + + + // --- ๋ฐ์ดํ„ฐ ๋ถ„ํฌ ๋กœ๊น… (Read Model) --- + + private void logReadModelDataDistribution() throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM product_read_model")) { + rs.next(); + log.info(" ์ „์ฒด ์ƒํ’ˆ (Read Model): {}๊ฑด", rs.getLong("cnt")); + } + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ํ™œ์„ฑ ์ƒํ’ˆ (Read Model): {}๊ฑด", rs.getLong("cnt")); + } + + try (ResultSet rs = stmt.executeQuery( + "SELECT MIN(price) AS min_p, MAX(price) AS max_p, AVG(price) AS avg_p FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ๊ฐ€๊ฒฉ (Read Model): min={}, max={}, avg={}", + rs.getBigDecimal("min_p"), rs.getBigDecimal("max_p"), + rs.getBigDecimal("avg_p").setScale(0, RoundingMode.HALF_UP)); + } + + try (ResultSet rs = stmt.executeQuery( + "SELECT MIN(like_count) AS min_l, MAX(like_count) AS max_l, AVG(like_count) AS avg_l FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ์ข‹์•„์š” (Read Model): min={}, max={}, avg={}", + rs.getLong("min_l"), rs.getLong("max_l"), + rs.getBigDecimal("avg_l").setScale(0, RoundingMode.HALF_UP)); + } + } + } + + + // --- ์ธ๋ฑ์Šค ๋กœ๊น… --- + + private void logIndexes(String table) throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW INDEX FROM " + table)) { + + StringBuilder sb = new StringBuilder(); + sb.append(String.format(" %-25s %-20s %-10s%n", "Key_name", "Column_name", "Non_unique")); + + while (rs.next()) { + sb.append(String.format(" %-25s %-20s %-10d%n", + rs.getString("Key_name"), + rs.getString("Column_name"), + rs.getInt("Non_unique"))); + } + + log.info("[{}]\n{}", table, sb); + } + } + + + // --- ํŒŒ์ผ ์ถœ๋ ฅ ๋ฒ„์ „ (TO-BE ์ „์šฉ) --- + + private void logIndexesToFile(String table) throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SHOW INDEX FROM " + table)) { + StringBuilder sb = new StringBuilder(); + sb.append(String.format(" %-25s %-20s %-10s", "Key_name", "Column_name", "Non_unique")); + while (rs.next()) { + sb.append(String.format("\n %-25s %-20s %-10d", + rs.getString("Key_name"), rs.getString("Column_name"), rs.getInt("Non_unique"))); + } + out(sb.toString()); + } + } + + private void logReadModelDataDistributionToFile() throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM product_read_model")) { + rs.next(); + out(String.format(" ์ „์ฒด ์ƒํ’ˆ (Read Model): %d๊ฑด", rs.getLong("cnt"))); + } + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + out(String.format(" ํ™œ์„ฑ ์ƒํ’ˆ (Read Model): %d๊ฑด", rs.getLong("cnt"))); + } + try (ResultSet rs = stmt.executeQuery("SELECT MIN(price) AS min_p, MAX(price) AS max_p, AVG(price) AS avg_p FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + out(String.format(" ๊ฐ€๊ฒฉ (Read Model): min=%s, max=%s, avg=%s", + rs.getBigDecimal("min_p"), rs.getBigDecimal("max_p"), + rs.getBigDecimal("avg_p").setScale(0, RoundingMode.HALF_UP))); + } + try (ResultSet rs = stmt.executeQuery("SELECT MIN(like_count) AS min_l, MAX(like_count) AS max_l, AVG(like_count) AS avg_l FROM product_read_model WHERE deleted_at IS NULL")) { + rs.next(); + out(String.format(" ์ข‹์•„์š” (Read Model): min=%d, max=%d, avg=%s", + rs.getLong("min_l"), rs.getLong("max_l"), + rs.getBigDecimal("avg_l").setScale(0, RoundingMode.HALF_UP))); + } + } + } + + private void measureSingleQueryToFile(String dataLabel, String ucLabel, String sql) throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + // EXPLAIN + try (ResultSet rs = stmt.executeQuery("EXPLAIN " + sql)) { + while (rs.next()) { + out(String.format("[%s] [EXPLAIN] %s โ€” table=%s, type=%s, key=%s, rows=%s, filtered=%s, Extra=%s", + dataLabel, ucLabel, rs.getString("table"), rs.getString("type"), + rs.getString("key"), rs.getString("rows"), rs.getString("filtered"), rs.getString("Extra"))); + } + } + // Warmup + for (int w = 0; w < 3; w++) { + try (ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) {} } + } + // ์ธก์ • + int runs = 5; + long[] times = new long[runs]; + for (int i = 0; i < runs; i++) { + long start = System.nanoTime(); + try (ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) {} } + times[i] = System.nanoTime() - start; + } + long sum = 0, min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long t : times) { sum += t; min = Math.min(min, t); max = Math.max(max, t); } + out(String.format("[%s] [๋‹จ์ผ์ฟผ๋ฆฌ] %s โ€” avg=%.2fms, min=%.2fms, max=%.2fms", + dataLabel, ucLabel, sum / (double) runs / 1_000_000.0, + min / 1_000_000.0, max / 1_000_000.0)); + } + } + + private void measureBurstToFile(String dataLabel, String ucLabel, String sql) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(BURST_THREADS); + CountDownLatch ready = new CountDownLatch(BURST_THREADS); + CountDownLatch go = new CountDownLatch(1); + long[] latencies = new long[BURST_THREADS]; + AtomicInteger errors = new AtomicInteger(0); + for (int i = 0; i < BURST_THREADS; i++) { + final int idx = i; + executor.submit(() -> { + ready.countDown(); + try { + go.await(); + long start = System.nanoTime(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) {} } + latencies[idx] = System.nanoTime() - start; + } catch (Exception e) { errors.incrementAndGet(); latencies[idx] = -1; } + }); + } + ready.await(); go.countDown(); + executor.shutdown(); executor.awaitTermination(5, TimeUnit.MINUTES); + long[] valid = Arrays.stream(latencies).filter(l -> l > 0).sorted().toArray(); + if (valid.length > 0) { + out(String.format("[%s] [๋ฒ„์ŠคํŠธ] %s โ€” ์™„๋ฃŒ: %d/%d, ์—๋Ÿฌ: %d, avg=%.2fms, p50=%.2fms, p95=%.2fms, p99=%.2fms, max=%.2fms", + dataLabel, ucLabel, valid.length, BURST_THREADS, errors.get(), + avg(valid), percentile(valid, 50), percentile(valid, 95), + percentile(valid, 99), valid[valid.length - 1] / 1_000_000.0)); + } else { + out(String.format("[%s] [๋ฒ„์ŠคํŠธ] %s โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: %d)", dataLabel, ucLabel, errors.get())); + } + } + + private void measureSustainedLoadToFile(String dataLabel, String ucLabel, String sql) throws Exception { + int totalRequests = SUSTAINED_RPS * SUSTAINED_DURATION_SEC; + long intervalMs = 1000 / SUSTAINED_RPS; + ExecutorService executor = Executors.newFixedThreadPool(SUSTAINED_RPS * 2); + List latencies = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger errors = new AtomicInteger(0); + long testStart = System.nanoTime(); + for (int i = 0; i < totalRequests; i++) { + executor.submit(() -> { + long start = System.nanoTime(); + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) {} latencies.add(System.nanoTime() - start); } + catch (Exception e) { errors.incrementAndGet(); } + }); + long elapsed = (System.nanoTime() - testStart) / 1_000_000; + long expected = (long) (i + 1) * intervalMs; + long sleepMs = expected - elapsed; + if (sleepMs > 0) Thread.sleep(sleepMs); + } + executor.shutdown(); boolean finished = executor.awaitTermination(5, TimeUnit.MINUTES); + long totalTimeMs = (System.nanoTime() - testStart) / 1_000_000; + long[] sorted = latencies.stream().mapToLong(l -> l).sorted().toArray(); + if (sorted.length > 0) { + double actualQps = (double) sorted.length / totalTimeMs * 1000; + out(String.format("[%s] [์ง€์†๋ถ€ํ•˜] %s โ€” ์™„๋ฃŒ: %d/%d, ์—๋Ÿฌ: %d, ์‹ค์ œQPS: %.1f, avg=%.2fms, p50=%.2fms, p95=%.2fms, p99=%.2fms, ์ด์‹œ๊ฐ„=%dms%s", + dataLabel, ucLabel, sorted.length, totalRequests, errors.get(), actualQps, + avg(sorted), percentile(sorted, 50), percentile(sorted, 95), + percentile(sorted, 99), totalTimeMs, finished ? "" : " [TIMEOUT]")); + } else { + out(String.format("[%s] [์ง€์†๋ถ€ํ•˜] %s โ€” ์ „์ฒด ์‹คํŒจ (์—๋Ÿฌ: %d)", dataLabel, ucLabel, errors.get())); + } + } + + + // --- ๋ฐ์ดํ„ฐ ๋ถ„ํฌ ๋กœ๊น… --- + + private void logDataDistribution() throws Exception { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM products")) { + rs.next(); + log.info(" ์ „์ฒด ์ƒํ’ˆ: {}๊ฑด", rs.getLong("cnt")); + } + try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS cnt FROM products WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ํ™œ์„ฑ ์ƒํ’ˆ: {}๊ฑด", rs.getLong("cnt")); + } + + try (ResultSet rs = stmt.executeQuery( + "SELECT MIN(price) AS min_p, MAX(price) AS max_p, AVG(price) AS avg_p FROM products WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ๊ฐ€๊ฒฉ: min={}, max={}, avg={}", + rs.getBigDecimal("min_p"), rs.getBigDecimal("max_p"), + rs.getBigDecimal("avg_p").setScale(0, RoundingMode.HALF_UP)); + } + + try (ResultSet rs = stmt.executeQuery( + "SELECT MIN(stock) AS min_s, MAX(stock) AS max_s, AVG(stock) AS avg_s FROM products WHERE deleted_at IS NULL")) { + rs.next(); + log.info(" ์žฌ๊ณ : min={}, max={}, avg={}", + rs.getLong("min_s"), rs.getLong("max_s"), + rs.getBigDecimal("avg_s").setScale(0, RoundingMode.HALF_UP)); + } + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/cart/cart/infrastructure/entity/CartItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/cart/cart/infrastructure/entity/CartItemEntity.java index 674c11f15..21b461567 100644 --- a/apps/commerce-api/src/main/java/com/loopers/cart/cart/infrastructure/entity/CartItemEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/cart/cart/infrastructure/entity/CartItemEntity.java @@ -16,9 +16,15 @@ * - selected: ์„ ํƒ ์—ฌ๋ถ€ */ @Entity -@Table(name = "cart_items", uniqueConstraints = { - @UniqueConstraint(columnNames = {"user_id", "product_id"}) -}) +@Table(name = "cart_items", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}), + indexes = { + // ์„ ํƒ๋œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ํ•ญ๋ชฉ ์กฐํšŒ: WHERE user_id = ? AND selected = true + @Index(name = "idx_cart_user_selected", columnList = "user_id, selected"), + // ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ฆฌ: WHERE product_id = ? + @Index(name = "idx_cart_product", columnList = "product_id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CartItemEntity extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/brand/infrastructure/entity/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/catalog/brand/infrastructure/entity/BrandEntity.java index d259302ef..85bedbf8b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/brand/infrastructure/entity/BrandEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/brand/infrastructure/entity/BrandEntity.java @@ -16,7 +16,10 @@ * - visibleStatus: ๋…ธ์ถœ ์ƒํƒœ */ @Entity -@Table(name = "brands") +@Table(name = "brands", indexes = { + // ์‚ฌ์šฉ์ž ์กฐํšŒ: WHERE deleted_at IS NULL AND visible_status = ? + @Index(name = "idx_brands_deleted_visible", columnList = "deleted_at, visible_status") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class BrandEntity extends SoftDeleteBaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductEntity.java index d74ab3346..9215d681c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductEntity.java @@ -17,7 +17,6 @@ * - price: ๊ฐ€๊ฒฉ * - stock: ์žฌ๊ณ  * - description: ์ƒํ’ˆ ์„ค๋ช… - * - likeCount: ์ข‹์•„์š” ์ˆ˜ */ @Entity @Table(name = "products") @@ -40,32 +39,28 @@ public class ProductEntity extends SoftDeleteBaseEntity { @Column(name = "description", length = 1000) private String description; - @Column(name = "like_count", nullable = false) - private Long likeCount; - private ProductEntity(Long id, Long brandId, String name, BigDecimal price, Long stock, - String description, Long likeCount) { + String description) { super(id); this.brandId = brandId; this.name = name; this.price = price; this.stock = stock; this.description = description; - this.likeCount = likeCount; } // DB ๋ณต์›์šฉ (id ํฌํ•จ) public static ProductEntity of(Long id, Long brandId, String name, BigDecimal price, Long stock, - String description, Long likeCount) { - return new ProductEntity(id, brandId, name, price, stock, description, likeCount); + String description) { + return new ProductEntity(id, brandId, name, price, stock, description); } // ์‹ ๊ทœ ์ƒ์„ฑ์šฉ (id = null) public static ProductEntity of(Long brandId, String name, BigDecimal price, Long stock, - String description, Long likeCount) { - return of(null, brandId, name, price, stock, description, likeCount); + String description) { + return of(null, brandId, name, price, stock, description); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/coupon/coupontemplate/infrastructure/entity/CouponTemplateEntity.java b/apps/commerce-api/src/main/java/com/loopers/coupon/coupontemplate/infrastructure/entity/CouponTemplateEntity.java index 8dc6f4002..7895d7f3a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/coupon/coupontemplate/infrastructure/entity/CouponTemplateEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/coupon/coupontemplate/infrastructure/entity/CouponTemplateEntity.java @@ -21,7 +21,10 @@ * - expiredAt: ๋งŒ๋ฃŒ ์ผ์‹œ */ @Entity -@Table(name = "coupon_template") +@Table(name = "coupon_template", indexes = { + // ํ™œ์„ฑ ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก: WHERE deleted_at IS NULL + @Index(name = "idx_coupon_template_deleted", columnList = "deleted_at") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CouponTemplateEntity extends SoftDeleteBaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/coupon/issuedcoupon/infrastructure/entity/IssuedCouponEntity.java b/apps/commerce-api/src/main/java/com/loopers/coupon/issuedcoupon/infrastructure/entity/IssuedCouponEntity.java index c01763e0e..3eca9b5c0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/coupon/issuedcoupon/infrastructure/entity/IssuedCouponEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/coupon/issuedcoupon/infrastructure/entity/IssuedCouponEntity.java @@ -17,7 +17,15 @@ * - 1์ธ 1์ฟ ํฐ: (user_id, coupon_template_id) ๋ณตํ•ฉ ์œ ๋‹ˆํฌ ์ œ์•ฝ์ด DB ๋ ˆ๋ฒจ์—์„œ ๋ณด์žฅ */ @Entity -@Table(name = "issued_coupon", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "coupon_template_id"})) +@Table(name = "issued_coupon", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "coupon_template_id"}), + indexes = { + // ์‚ฌ์šฉ์ž ์ฟ ํฐ ๋‚ด์—ญ: WHERE user_id = ? ORDER BY created_at DESC + @Index(name = "idx_issued_coupon_user_created", columnList = "user_id, created_at"), + // ๊ด€๋ฆฌ์ž ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ: WHERE coupon_template_id = ? ORDER BY created_at DESC + @Index(name = "idx_issued_coupon_template_created", columnList = "coupon_template_id, created_at") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class IssuedCouponEntity extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/engagement/productlike/infrastructure/entity/ProductLikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/engagement/productlike/infrastructure/entity/ProductLikeEntity.java index ec2cda1d4..425ee8d55 100644 --- a/apps/commerce-api/src/main/java/com/loopers/engagement/productlike/infrastructure/entity/ProductLikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/engagement/productlike/infrastructure/entity/ProductLikeEntity.java @@ -15,7 +15,15 @@ * - targetId: ์ƒํ’ˆ ID */ @Entity -@Table(name = "likes", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "target_type", "target_id"})) +@Table(name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "target_type", "target_id"}), + indexes = { + // ์ข‹์•„์š” ๋ชฉ๋ก ํŽ˜์ด์ง€๋„ค์ด์…˜: WHERE user_id = ? AND target_type = ? ORDER BY created_at DESC + @Index(name = "idx_likes_user_type_created", columnList = "user_id, target_type, created_at"), + // ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ •๋ฆฌ: WHERE target_type = ? AND target_id = ? + @Index(name = "idx_likes_type_target", columnList = "target_type, target_id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductLikeEntity extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderEntity.java index cb16142b6..3da63239a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderEntity.java @@ -2,10 +2,7 @@ import com.loopers.domain.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,7 +23,13 @@ * - couponSnapshotValue: ์ฟ ํฐ ํ• ์ธ ๊ฐ’ ์Šค๋ƒ…์ƒท (nullable) */ @Entity -@Table(name = "orders", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "request_id"})) +@Table(name = "orders", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "request_id"}), + indexes = { + // ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ: WHERE user_id = ? ORDER BY created_at DESC / ๊ธฐ๊ฐ„ ํ•„ํ„ฐ + @Index(name = "idx_orders_user_created", columnList = "user_id, created_at") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderEntity extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderItemEntity.java index fbb494bb3..5c7828022 100644 --- a/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderItemEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/ordering/order/infrastructure/entity/OrderItemEntity.java @@ -2,9 +2,7 @@ import com.loopers.domain.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -21,7 +19,10 @@ * - quantity: ์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰ */ @Entity -@Table(name = "order_items") +@Table(name = "order_items", indexes = { + // ์ฃผ๋ฌธ ์ƒํ’ˆ ์กฐํšŒ: WHERE order_id = ? / WHERE order_id IN (?) + @Index(name = "idx_order_items_order", columnList = "order_id") +}) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderItemEntity extends BaseEntity { diff --git a/round5-docs/04-to-be-index-measurement.md b/round5-docs/04-to-be-index-measurement.md new file mode 100644 index 000000000..65b652bda --- /dev/null +++ b/round5-docs/04-to-be-index-measurement.md @@ -0,0 +1,512 @@ +# TO-BE ์ธ๋ฑ์Šค ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค) + +> ์‹ค์ธก ์žฌํ˜„ ๋ช…๋ น์–ด: `./gradlew :apps:commerce-api:benchmarkTest --tests '*ProductIndexPerformanceTest.measureToBe*'` + +## ์ธก์ • ํ™˜๊ฒฝ + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| DB | MySQL 8.0 (TestContainers) | +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | 10๋งŒ / 100๋งŒ / 1000๋งŒ | +| ๋ธŒ๋žœ๋“œ | 50๊ฐœ (๊ท ๋“ฑ ๋ถ„ํฌ) | +| ์ƒํ’ˆ ์ƒํƒœ | ์ „๋ถ€ ํ™œ์„ฑ (deleted_at IS NULL) | +| **ํ…Œ์ด๋ธ”** | **`product_read_model` (๋น„์ •๊ทœํ™” Read Model)** | +| **์ธ๋ฑ์Šค** | **PK + ๋ณตํ•ฉ ์ธ๋ฑ์Šค 12๊ฐœ (6๊ฐœ ์œ ์ฆˆ์ผ€์ด์Šค ร— 2-column/3-column)** | +| **์ฟผ๋ฆฌ ํŒจํ„ด** | **๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT (LEFT JOIN ์ œ๊ฑฐ)** | +| Connection Pool | HikariCP (๊ธฐ๋ณธ 10๊ฐœ) | + +### AS-IS ๋Œ€๋น„ ๋ณ€๊ฒฝ์  + +| ํ•ญ๋ชฉ | AS-IS | TO-BE | +|------|-------|-------| +| ํ…Œ์ด๋ธ” | `products` + `brands` (์ •๊ทœํ™”) | `product_read_model` (๋น„์ •๊ทœํ™”, `brand_name` ์ปฌ๋Ÿผ ํฌํ•จ) | +| ์กฐ์ธ | `LEFT JOIN brands` ํ•„์ˆ˜ | ์กฐ์ธ ๋ถˆํ•„์š” (๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT) | +| ์ธ๋ฑ์Šค | PK๋งŒ ์กด์žฌ | PK + ๋ณตํ•ฉ ์ธ๋ฑ์Šค 12๊ฐœ | +| EXPLAIN type | `ALL` (Full Table Scan) | `range` / `ref` (Index Range Scan) | +| filesort | ๋ชจ๋“  ์ฟผ๋ฆฌ์—์„œ ๋ฐœ์ƒ | ๋ชจ๋“  ์ •๋ ฌ ์ฟผ๋ฆฌ์—์„œ ์ œ๊ฑฐ | + +## ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์„ค๊ณ„ + +```sql +-- ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? AND deleted_at IS NULL ORDER BY {sort_col} +CREATE INDEX idx_read_brand_deleted_created ON product_read_model (brand_id, deleted_at, created_at); +CREATE INDEX idx_read_brand_deleted_price ON product_read_model (brand_id, deleted_at, price); +CREATE INDEX idx_read_brand_deleted_likecount ON product_read_model (brand_id, deleted_at, like_count); + +-- ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ๋ฏธ์ง€์ •): WHERE deleted_at IS NULL ORDER BY {sort_col} +CREATE INDEX idx_read_deleted_created ON product_read_model (deleted_at, created_at); +CREATE INDEX idx_read_deleted_price ON product_read_model (deleted_at, price); +CREATE INDEX idx_read_deleted_likecount ON product_read_model (deleted_at, like_count); + +-- ๊ด€๋ฆฌ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? ORDER BY {sort_col} +CREATE INDEX idx_read_brand_created ON product_read_model (brand_id, created_at); +CREATE INDEX idx_read_brand_price ON product_read_model (brand_id, price); +CREATE INDEX idx_read_brand_likecount ON product_read_model (brand_id, like_count); + +-- ๊ด€๋ฆฌ์ž ์กฐํšŒ (ํ•„ํ„ฐ ์—†์Œ): ORDER BY {sort_col} +CREATE INDEX idx_read_created ON product_read_model (created_at); +CREATE INDEX idx_read_price ON product_read_model (price); +CREATE INDEX idx_read_likecount ON product_read_model (like_count); +``` + +### ์ธ๋ฑ์Šค ์„ค๊ณ„ ๊ทผ๊ฑฐ + +**์ปฌ๋Ÿผ ์ˆœ์„œ: `(brand_id, deleted_at, sort_column)`** + +B-tree ์ธ๋ฑ์Šค์—์„œ **equality ์กฐ๊ฑด ์ปฌ๋Ÿผ์€ sort ์ปฌ๋Ÿผ๋ณด๋‹ค ๋ฐ˜๋“œ์‹œ ์•ž์—** ์™€์•ผ ํ•œ๋‹ค. ์ด ์›์น™์— ๋”ฐ๋ผ ๊ฐ ์œ„์น˜๊ฐ€ ๊ฒฐ์ •๋œ๋‹ค. + +**1. `sort_column`์ด ๋ฐ˜๋“œ์‹œ ๋งˆ์ง€๋ง‰(3๋ฒˆ์งธ)์ธ ์ด์œ ** + +`sort_column`(created_at, price, like_count)์€ `ORDER BY`์— ์‚ฌ์šฉ๋œ๋‹ค. B-tree ์ธ๋ฑ์Šค์—์„œ **๋ชจ๋“  equality ์ปฌ๋Ÿผ ๋’ค์— sort ์ปฌ๋Ÿผ์ด ์—ฐ์†์œผ๋กœ ์˜ค๋ฉด**, MySQL์€ ์ธ๋ฑ์Šค์— ์ด๋ฏธ ์ •๋ ฌ๋œ ์ˆœ์„œ๋กœ ํ–‰์„ ์ฝ์„ ์ˆ˜ ์žˆ์–ด **filesort๋ฅผ ์ƒ๋žต**ํ•œ๋‹ค. ๋งŒ์•ฝ sort ์ปฌ๋Ÿผ์ด equality ์ปฌ๋Ÿผ ์‚ฌ์ด์— ๋ผ์–ด ์žˆ์œผ๋ฉด, sort ์ปฌ๋Ÿผ ์ดํ›„์˜ equality ์กฐ๊ฑด์ด ์ธ๋ฑ์Šค ์—ฐ์† ํƒ์ƒ‰์„ ๊นจ๋œจ๋ ค filesort๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. + +``` +(brand_id, deleted_at, created_at) โ†’ brand_id=1, deleted_at IS NULL๊นŒ์ง€ equality๋กœ ์ขํžŒ ํ›„ + created_at ์ˆœ์„œ๋Œ€๋กœ 20ํ–‰๋งŒ ์ฝ๊ธฐ โ†’ filesort ๋ถˆํ•„์š” โœ… + +(brand_id, created_at, deleted_at) โ†’ brand_id=1๋กœ ์ขํžŒ ํ›„ created_at ์ˆœ์„œ๋กœ ์ฝ์ง€๋งŒ, + ๊ฐ ํ–‰๋งˆ๋‹ค deleted_at IS NULL ํ•„ํ„ฐ๋ง ํ•„์š” โ†’ filesort ๋ถˆํ•„์š”ํ•˜๋‚˜ ๋ถˆํ•„์š”ํ•œ ํ–‰ ์ฝ๊ธฐ ์ฆ๊ฐ€ โš ๏ธ + +(created_at, brand_id, deleted_at) โ†’ created_at์€ range/sort ์กฐ๊ฑด โ†’ ์ดํ›„ ์ปฌ๋Ÿผ ํ™œ์šฉ ๋ถˆ๊ฐ€ โ†’ filesort ๋ฐœ์ƒ โŒ +``` + +**2. `deleted_at`์ด 2๋ฒˆ์งธ(์ค‘๊ฐ„)์ธ ์ด์œ ** + +`deleted_at IS NULL`์€ MySQL์—์„œ **equality(ref) ์กฐ๊ฑด์œผ๋กœ ์ฒ˜๋ฆฌ**๋œ๋‹ค. ๋”ฐ๋ผ์„œ `brand_id`์™€ `deleted_at` ๋ชจ๋‘ equality ์ปฌ๋Ÿผ์ด๊ณ , equality ์ปฌ๋Ÿผ๋ผ๋ฆฌ๋Š” ์ˆœ์„œ๊ฐ€ ๋ฐ”๋€Œ์–ด๋„ ์ธ๋ฑ์Šค ํƒ์ƒ‰ ๊ฒฐ๊ณผ(matching rows)๊ฐ€ ๋™์ผํ•˜๋‹ค. ์ค‘์š”ํ•œ ๊ฒƒ์€ **์ด ๋‘ ์ปฌ๋Ÿผ์ด sort ์ปฌ๋Ÿผ๋ณด๋‹ค ์•ž์— ์žˆ์–ด์•ผ ํ•œ๋‹ค**๋Š” ์ ์ด๋‹ค. + +**3. `brand_id`๊ฐ€ 1๋ฒˆ์งธ(์„ ๋‘)์ธ ์ด์œ  โ€” equality ์ปฌ๋Ÿผ ๊ฐ„ ์ˆœ์„œ** + +`brand_id`์™€ `deleted_at`์€ ๋‘˜ ๋‹ค equality ์กฐ๊ฑด์ด๋ฏ€๋กœ ์ˆœ์„œ๊ฐ€ ๋ฐ”๋€Œ์–ด๋„ ๊ฒฐ๊ณผ๋Š” ๋™์ผํ•˜๋‹ค. ํ•˜์ง€๋งŒ **์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ ์ปฌ๋Ÿผ์„ ์„ ๋‘์— ๋ฐฐ์น˜**ํ•˜๋ฉด B-tree ์ฒซ ๋ ˆ๋ฒจ ๋ถ„๊ธฐ๊ฐ€ ๊ท ๋“ฑํ•ด์ ธ ์ธ๋ฑ์Šค ํŽ˜์ด์ง€ ์ ‘๊ทผ ํšจ์œจ์ด ํ–ฅ์ƒ๋œ๋‹ค. + +| ์ปฌ๋Ÿผ | ์นด๋””๋„๋ฆฌํ‹ฐ | ์—ญํ•  | +|------|----------|------| +| `brand_id` | ๋†’์Œ (50๊ฐœ distinct) | equality ์กฐ๊ฑด (์„ ํƒ์  ํ•„ํ„ฐ) | +| `deleted_at` | ๋‚ฎ์Œ (2๊ฐ’: NULL/timestamp) | equality ์กฐ๊ฑด (ํ•ญ์ƒ `IS NULL`) | +| `sort_column` | ๋†’์Œ (์—ฐ์†๊ฐ’) | ORDER BY ์ •๋ ฌ | + +> **์š”์•ฝ**: `sort_column`์€ filesort ์ œ๊ฑฐ๋ฅผ ์œ„ํ•ด **๋ฐ˜๋“œ์‹œ ๋งˆ์ง€๋ง‰**. `brand_id`์™€ `deleted_at`์€ ๋‘˜ ๋‹ค equality์ด๋ฏ€๋กœ sort ์•ž์— ๋ฐฐ์น˜ํ•˜๋˜, ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ `brand_id`๋ฅผ ์„ ๋‘์— ๋‘”๋‹ค. + +**๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์œ ๋ฌด์— ๋”ฐ๋ฅธ ๋™์ž‘ ์ฐจ์ด:** + +| ์กฐ๊ฑด | ์ธ๋ฑ์Šค ์‚ฌ์šฉ | filesort | +|------|-----------|----------| +| `brand_id = 1 AND deleted_at IS NULL ORDER BY created_at` | `(brand_id, deleted_at, created_at)` 3์ปฌ๋Ÿผ ๋ชจ๋‘ ํ™œ์šฉ | **์ œ๊ฑฐ** (์ธ๋ฑ์Šค ์ˆœ์„œ = ์ •๋ ฌ ์ˆœ์„œ) | +| `deleted_at IS NULL ORDER BY created_at` | `(deleted_at, created_at)` 2-column ์ธ๋ฑ์Šค ํ™œ์šฉ | **์ œ๊ฑฐ** (์ธ๋ฑ์Šค ์ˆœ์„œ = ์ •๋ ฌ ์ˆœ์„œ) | + +## ์ธก์ • ๋ ˆ๋ฒจ + +| ๋ ˆ๋ฒจ | ์ธก์ • ๋Œ€์ƒ | ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค | ๋น„๊ต ๋ชฉ์  | +|------|----------|-------------|----------| +| **DB ์ฟผ๋ฆฌ** | ์ˆœ์ˆ˜ SQL ์‹คํ–‰ (JDBC ์ง์ ‘ ํ˜ธ์ถœ) | `ProductIndexPerformanceTest` | ์ธ๋ฑ์Šค ํšจ๊ณผ ๋น„๊ต | + +## ํŠธ๋ž˜ํ”ฝ ์œ ํ˜• + +| ์œ ํ˜• | ํŒŒ๋ผ๋ฏธํ„ฐ | ์„ค๋ช… | +|------|---------|------| +| **๋‹จ์ผ ์ฟผ๋ฆฌ** | 1 thread, warmup 3ํšŒ + ์ธก์ • 5ํšŒ | EXPLAIN + ์ˆœ์ˆ˜ ์ฟผ๋ฆฌ ์‹คํ–‰์‹œ๊ฐ„ | +| **๋ฒ„์ŠคํŠธ** | 100 concurrent threads, CountDownLatch ๋™์‹œ ์‹œ์ž‘ | ๋™์‹œ ์š”์ฒญ ํญ์ฃผ ์‹œ๋‚˜๋ฆฌ์˜ค | +| **์ง€์† ๋ถ€ํ•˜** | 20 RPS ร— 10์ดˆ = 200 ์š”์ฒญ | ์ผ์ • ํŠธ๋ž˜ํ”ฝ ์œ ์ง€ ์‹œ๋‚˜๋ฆฌ์˜ค | + +## ๋ฐ์ดํ„ฐ ๋ถ„ํฌ + +| ํ•ญ๋ชฉ | 10๋งŒ๊ฑด | 100๋งŒ๊ฑด | 1000๋งŒ๊ฑด | +|------|:---:|:---:|:---:| +| ์ „์ฒด/ํ™œ์„ฑ ์ƒํ’ˆ | 100,000 | 1,000,000 | 10,000,000 | +| ๋ธŒ๋žœ๋“œ๋‹น ์ƒํ’ˆ ์ˆ˜ | ~2,000 | ~20,000 | ~200,000 | +| ๊ฐ€๊ฒฉ ๋ฒ”์œ„ | 1,000 ~ 100,000 | 1,000 ~ 100,000 | 1,000 ~ 100,000 | +| ์ข‹์•„์š” ๋ฒ”์œ„ | 0 ~ 10,000 | 0 ~ 10,000 | 0 ~ 10,000 | + +## ํ˜„์žฌ ์ธ๋ฑ์Šค + +``` +[product_read_model] + Key_name Column_name Non_unique + PRIMARY id 0 + idx_read_brand_deleted_created brand_id, deleted_at, created_at 1 + idx_read_brand_deleted_price brand_id, deleted_at, price 1 + idx_read_brand_deleted_likecount brand_id, deleted_at, like_count 1 + idx_read_deleted_created deleted_at, created_at 1 + idx_read_deleted_price deleted_at, price 1 + idx_read_deleted_likecount deleted_at, like_count 1 + idx_read_brand_created brand_id, created_at 1 + idx_read_brand_price brand_id, price 1 + idx_read_brand_likecount brand_id, like_count 1 + idx_read_created created_at 1 + idx_read_price price 1 + idx_read_likecount like_count 1 +``` + +--- + +# A. DB ์ฟผ๋ฆฌ ๋ ˆ๋ฒจ + +## A-1. ๋‹จ์ผ ์ฟผ๋ฆฌ ์ธก์ • (EXPLAIN + ์‹คํ–‰์‹œ๊ฐ„) + +### EXPLAIN ๊ฒฐ๊ณผ + +#### ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์žˆ๋Š” ์ฟผ๋ฆฌ (UC4~6) + +**10๋งŒ๊ฑด ๊ธฐ์ค€:** +``` +UC4 (LATEST): type=ref, key=idx_read_brand_deleted_created, rows=2002, Extra=Using where; Backward index scan +UC5 (PRICE_ASC): type=ref, key=idx_read_brand_deleted_price, rows=2002, Extra=Using index condition +UC6 (LIKES_DESC): type=ref, key=idx_read_brand_deleted_likecount, rows=2002, Extra=Using where; Backward index scan +``` + +**100๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +UC4 (LATEST): type=ref, key=idx_read_brand_created, rows=35384, filtered=50.0, Extra=Using where; Backward index scan +UC5 (PRICE_ASC): type=ref, key=idx_read_brand_price, rows=35384, filtered=50.0, Extra=Using where +UC6 (LIKES_DESC): type=ref, key=idx_read_brand_likecount, rows=35384, filtered=50.0, Extra=Using where; Backward index scan +``` + +**1000๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +UC4 (LATEST): type=ref, key=idx_read_brand_created, rows=418906, filtered=50.0, Extra=Using where; Backward index scan +UC5 (PRICE_ASC): type=ref, key=idx_read_brand_price, rows=418906, filtered=50.0, Extra=Using where +UC6 (LIKES_DESC): type=ref, key=idx_read_brand_likecount, rows=418906, filtered=50.0, Extra=Using where; Backward index scan +``` + +- **type=ref**: `brand_id = 1 AND deleted_at IS NULL` equality match (AS-IS์˜ `ALL`์—์„œ ๊ฐœ์„ ) +- **10๋งŒ๊ฑด**: 3-column ๋ณตํ•ฉ ์ธ๋ฑ์Šค `idx_read_brand_deleted_*` ์‚ฌ์šฉ, rows=2,002 +- **100๋งŒ๊ฑด**: MySQL ์˜ตํ‹ฐ๋งˆ์ด์ €๊ฐ€ 2-column ์ธ๋ฑ์Šค `idx_read_brand_*` ์„ ํƒ, rows=35,384 (filtered=50%) +- **1000๋งŒ๊ฑด**: 2-column ์ธ๋ฑ์Šค `idx_read_brand_*` ์„ ํƒ, rows=418,906 (filtered=50%). ์Šค์บ” ํ–‰ ์ˆ˜๊ฐ€ ์ฆ๊ฐ€ํ•˜์ง€๋งŒ ์ธ๋ฑ์Šค ์ •๋ ฌ ์ˆœ์„œ๋กœ LIMIT 20๋งŒ ์ฝ์–ด ์‘๋‹ต์‹œ๊ฐ„์€ 2~3ms ์œ ์ง€ +- **Extra=Backward index scan**: DESC ์ •๋ ฌ ์‹œ ์ธ๋ฑ์Šค๋ฅผ ์—ญ๋ฐฉํ–ฅ์œผ๋กœ ์ฝ์Œ (ASC๋Š” Using where). **filesort ์—†์Œ** + +#### ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†๋Š” ์ฟผ๋ฆฌ (UC1~3) + +**10๋งŒ๊ฑด ๊ธฐ์ค€:** +``` +UC1 (LATEST): type=ref, key=idx_read_deleted_created, rows=49646, Extra=Using where; Backward index scan +UC2 (PRICE_ASC): type=ref, key=idx_read_deleted_price, rows=49646, Extra=Using index condition +UC3 (LIKES_DESC): type=ref, key=idx_read_deleted_likecount, rows=49646, Extra=Using where; Backward index scan +``` + +**100๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +UC1 (LATEST): type=ref, key=idx_read_deleted_created, rows=496179, Extra=Using where; Backward index scan +UC2 (PRICE_ASC): type=ref, key=idx_read_deleted_price, rows=496179, Extra=Using index condition +UC3 (LIKES_DESC): type=ref, key=idx_read_deleted_likecount, rows=496179, Extra=Using where; Backward index scan +``` + +**1000๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +UC1 (LATEST): type=ref, key=idx_read_deleted_created, rows=4956825, filtered=100.0, Extra=Using where; Backward index scan +UC2 (PRICE_ASC): type=ref, key=idx_read_deleted_price, rows=4956825, filtered=100.0, Extra=Using index condition +UC3 (LIKES_DESC): type=ref, key=idx_read_deleted_likecount, rows=4956825, filtered=100.0, Extra=Using where; Backward index scan +``` + +- **type=ref**: `deleted_at IS NULL` equality match๋กœ ์ธ๋ฑ์Šค ์ง„์ž… (AS-IS์˜ `ALL`์—์„œ ๊ฐœ์„ ) +- **key=idx_read_deleted_{sort_col}**: ์ „์šฉ 2-column ์ธ๋ฑ์Šค `(deleted_at, sort_col)` ์‚ฌ์šฉ +- **rows**: 10๋งŒ๊ฑด=49,646 / 100๋งŒ๊ฑด=496,179 / 1000๋งŒ๊ฑด=4,956,825 (ํ™œ์„ฑ ์ƒํ’ˆ ์ˆ˜. LIMIT 20์œผ๋กœ ์ดˆ๋ฐ˜ 20ํ–‰๋งŒ ์‹ค์ œ ์ฝ์Œ) +- **1000๋งŒ๊ฑด**: EXPLAIN rows๊ฐ€ ~500๋งŒ์œผ๋กœ ์ฆ๊ฐ€ํ•˜์ง€๋งŒ, ์ธ๋ฑ์Šค ์ •๋ ฌ ์ˆœ์„œ = ORDER BY ์ˆœ์„œ์ด๋ฏ€๋กœ LIMIT 20ํ–‰๋งŒ ์ฝ๊ณ  ์ฆ‰์‹œ ๋ฐ˜ํ™˜. ์‹ค์ œ ์‘๋‹ต์‹œ๊ฐ„ 2~8ms +- **Extra=Backward index scan**: DESC ์ •๋ ฌ ์‹œ ์ธ๋ฑ์Šค ์—ญ๋ฐฉํ–ฅ ์ฝ๊ธฐ. **filesort ์—†์Œ** + +#### COUNT ์ฟผ๋ฆฌ + +**10๋งŒ๊ฑด ๊ธฐ์ค€:** +``` +COUNT brandId=X: type=ref, key=idx_read_deleted_price, rows=49646, Extra=Using where; Using index +COUNT brandId=1: type=ref, key=idx_read_brand_deleted_price, rows=2002, Extra=Using where; Using index +``` + +**100๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +COUNT brandId=X: type=ref, key=idx_read_deleted_price, rows=496179, Extra=Using where; Using index +COUNT brandId=1: type=ref, key=idx_read_brand_deleted_created, rows=36232, Extra=Using where; Using index +``` + +**1000๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก):** +``` +COUNT brandId=X: type=ref, key=idx_read_deleted_price, rows=4956825, filtered=100.0, Extra=Using where; Using index +COUNT brandId=1: type=ref, key=idx_read_brand_deleted_price, rows=428292, filtered=100.0, Extra=Using where; Using index +``` + +- **COUNT + ์ „์ฒด**: `type=ref`, `deleted_at IS NULL` equality match, Covering Index (ํ…Œ์ด๋ธ” ์ ‘๊ทผ ๋ถˆํ•„์š”) +- **COUNT + ๋ธŒ๋žœ๋“œ**: `type=ref`, `brand_id + deleted_at` equality match, Covering Index +- **1000๋งŒ๊ฑด**: COUNT(์ „์ฒด)๋Š” rows=4,956,825๋กœ ์ธ๋ฑ์Šค ์ „์ฒด๋ฅผ ์Šค์บ”ํ•ด์•ผ ํ•˜๋ฏ€๋กœ ~997ms ์†Œ์š”. COUNT(๋ธŒ๋žœ๋“œ)๋Š” rows=428,292๋กœ ๋ฒ”์œ„๊ฐ€ ์ข์•„ ~27ms + +### AS-IS vs TO-BE EXPLAIN ๋น„๊ต + +| ํ•ญ๋ชฉ | AS-IS | TO-BE (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ) | TO-BE (ํ•„ํ„ฐ ์—†์Œ) | +|------|-------|-------------------|-----------------| +| **type** | `ALL` | `ref` | `ref` | +| **key** | `null` | `idx_read_brand_deleted_*` | `idx_read_deleted_*` | +| **rows** | ์ „์ฒด ํ–‰ | ~2,000 (๋ธŒ๋žœ๋“œ๋‹น) | ~50,000 (ํ™œ์„ฑ ์ „์ฒด) | +| **Extra** | Using where; Using filesort | Backward index scan / Using index condition | Backward index scan / Using index condition | +| **์กฐ์ธ** | `LEFT JOIN brands` | ์—†์Œ (๋‹จ์ผ ํ…Œ์ด๋ธ”) | ์—†์Œ (๋‹จ์ผ ํ…Œ์ด๋ธ”) | +| **filesort** | ํ•ญ์ƒ ๋ฐœ์ƒ | **์ œ๊ฑฐ** | **์ œ๊ฑฐ** (์ „์šฉ 2-column ์ธ๋ฑ์Šค) | + +### ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ฟผ๋ฆฌ (SELECT + ORDER BY + LIMIT 20) + +```sql +-- TO-BE ์ฟผ๋ฆฌ ํŒจํ„ด (LEFT JOIN ์ œ๊ฑฐ, ๋‹จ์ผ ํ…Œ์ด๋ธ”) +SELECT id, brand_id, brand_name, name, price, stock, like_count +FROM product_read_model +WHERE deleted_at IS NULL [AND brand_id = ?] +ORDER BY {sort_column} +LIMIT 20 +``` + +| UC | ์กฐ๊ฑด | ์ •๋ ฌ | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|----|------|------|:---:|:---:|:---:|:---:|:---:| +| 1 | brandId=X | LATEST | **0.94** | **2.44** | **3.89** | 2.6 | 1.6 | +| 2 | brandId=X | PRICE_ASC | **1.16** | **1.06** | **2.73** | 0.9 | 2.6 | +| 3 | brandId=X | LIKES_DESC | **2.36** | **0.97** | **8.20** | 0.4 | 8.5 | +| 4 | brandId=1 | LATEST | **6.96** | **1.69** | **2.32** | 0.2 | 1.4 | +| 5 | brandId=1 | PRICE_ASC | **1.16** | **0.85** | **2.29** | 0.7 | 2.7 | +| 6 | brandId=1 | LIKES_DESC | **1.00** | **0.87** | **2.56** | 0.9 | 2.9 | + +- **10๋งŒ๊ฑด**: ๋ชจ๋“  ์œ ์ฆˆ์ผ€์ด์Šค์—์„œ 1~7ms. ์ธ๋ฑ์Šค ์ ์šฉ์œผ๋กœ Full Table Scan + filesort ์ œ๊ฑฐ. JVM ์›Œ๋ฐ์—… ๋ณ€๋™์œผ๋กœ UC4๊ฐ€ 6.96ms๋กœ ๋‹ค์†Œ ๋†’์Œ +- **100๋งŒ๊ฑด**: ์ „ ์œ ์ฆˆ์ผ€์ด์Šค 0.85~2.44ms. ๋ฐ์ดํ„ฐ 10๋ฐฐ ์ฆ๊ฐ€์—๋„ ์ธ๋ฑ์Šค LIMIT 20 ์กฐ๊ธฐ ์ข…๋ฃŒ๋กœ ์‘๋‹ต์‹œ๊ฐ„ ์˜คํžˆ๋ ค ๊ฐ์†Œ (JVM ์›Œ๋ฐ์—… ํšจ๊ณผ) +- **1000๋งŒ๊ฑด**: ์ „ ์œ ์ฆˆ์ผ€์ด์Šค 2.29~8.20ms. 100๋ฐฐ ๋ฐ์ดํ„ฐ ์ฆ๊ฐ€์—๋„ ํ•œ ์ž๋ฆฟ์ˆ˜ ms ์œ ์ง€. ์ธ๋ฑ์Šค ์ •๋ ฌ ์ˆœ์„œ๋กœ LIMIT 20ํ–‰๋งŒ ์ฝ์œผ๋ฏ€๋กœ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์— ๊ฑฐ์˜ ๋ฌด๊ด€ํ•œ ์„ฑ๋Šฅ ๋‹ฌ์„ฑ +- **์ฆ๊ฐ€์œจ ํ•ด์„**: 10๋งŒโ†’100๋งŒ์—์„œ 1.0 ๋ฏธ๋งŒ์ธ ๊ฒฝ์šฐ(UC3: 0.4๋ฐฐ, UC4: 0.2๋ฐฐ)๋Š” ์˜คํžˆ๋ ค ์‘๋‹ต์ด ๋นจ๋ผ์ง„ ๊ฒƒ์œผ๋กœ, ์ ˆ๋Œ€๊ฐ’ ์ฐจ์ด๊ฐ€ 1~5ms ์ˆ˜์ค€์ด๋ผ JVM ์›Œ๋ฐ์—…/์บ์‹œ ํšจ๊ณผ์— ์˜ํ•œ ๋ณ€๋™ + +### COUNT ์ฟผ๋ฆฌ + +```sql +SELECT COUNT(*) FROM product_read_model WHERE deleted_at IS NULL [AND brand_id = ?] +``` + +| ์กฐ๊ฑด | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | ์ฆ๊ฐ€์œจ 10๋งŒโ†’100๋งŒ (๋ฐฐ) | ์ฆ๊ฐ€์œจ 100๋งŒโ†’1000๋งŒ (๋ฐฐ) | +|------|:---:|:---:|:---:|:---:|:---:| +| brandId=X (์ „์ฒด) | **9.65** | **103.84** | **996.76** | 10.8 | 9.6 | +| brandId=1 | **0.74** | **3.46** | **27.23** | 4.7 | 7.9 | + +- **10๋งŒ๊ฑด**: ์ „์ฒด COUNT 9.65ms, ๋ธŒ๋žœ๋“œ COUNT 0.74ms. Covering Index๋กœ ํ…Œ์ด๋ธ” ์ ‘๊ทผ ์—†์ด ์ธ๋ฑ์Šค๋งŒ ์Šค์บ” +- **100๋งŒ๊ฑด**: ์ „์ฒด COUNT 103.84ms (10๋งŒ๊ฑด ๋Œ€๋น„ 10.8๋ฐฐ ์ฆ๊ฐ€), ๋ธŒ๋žœ๋“œ COUNT 3.46ms. ์ „์ฒด COUNT๋Š” ๋ฐ์ดํ„ฐ ์ฆ๊ฐ€์— ๋น„๋ก€ํ•˜์—ฌ ์ฆ๊ฐ€ (O(N)) +- **1000๋งŒ๊ฑด**: ์ „์ฒด COUNT 996.76ms (~1์ดˆ), ๋ธŒ๋žœ๋“œ COUNT 27.23ms. ์ „์ฒด COUNT๋Š” ํ™œ์„ฑ ์ƒํ’ˆ ์ „์ฒด๋ฅผ ์นด์šดํŠธํ•ด์•ผ ํ•˜๋ฏ€๋กœ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์— ์„ ํ˜• ๋น„๋ก€. ๋ธŒ๋žœ๋“œ COUNT๋Š” equality ๋ฒ”์œ„ ์ถ•์†Œ๋กœ 27ms ์œ ์ง€ + +### AS-IS ๋Œ€๋น„ ๊ฐœ์„ ์œจ (๋‹จ์ผ ์ฟผ๋ฆฌ) + +#### 10๋งŒ๊ฑด ๊ธฐ์ค€ + +| UC | AS-IS (ms) | TO-BE (ms) | ๊ฐœ์„ ์œจ | ๋‹จ์ถ•๋Ÿ‰ (ms) | ๊ฐœ์„  ์š”์ธ | +|----|:---:|:---:|:---:|:---:|------| +| UC1: brandId=X, LATEST | 27.68 | 0.94 | **29๋ฐฐ** | 26.74 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC2: brandId=X, PRICE_ASC | 33.44 | 1.16 | **29๋ฐฐ** | 32.28 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC3: brandId=X, LIKES_DESC | 25.69 | 2.36 | **11๋ฐฐ** | 23.33 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC4: brandId=1, LATEST | 21.88 | 6.96 | **3๋ฐฐ** | 14.92 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| UC5: brandId=1, PRICE_ASC | 22.11 | 1.16 | **19๋ฐฐ** | 20.95 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| UC6: brandId=1, LIKES_DESC | 20.80 | 1.00 | **21๋ฐฐ** | 19.80 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| COUNT: brandId=X | 10.59 | 9.65 | **1.1๋ฐฐ** | 0.94 | Covering Index (์ธ๋ฑ์Šค๋งŒ ์Šค์บ”) | +| COUNT: brandId=1 | 11.88 | 0.74 | **16๋ฐฐ** | 11.14 | Covering Index + equality ์ถ•์†Œ | + +- **SELECT ์ฟผ๋ฆฌ**: ์ „ ์œ ์ฆˆ์ผ€์ด์Šค์—์„œ 3~29๋ฐฐ ๊ฐœ์„ . ์ ˆ๋Œ€๊ฐ’์œผ๋กœ 14~32ms ๋‹จ์ถ•. AS-IS 20~33ms โ†’ TO-BE 0.94~6.96ms +- **COUNT(์ „์ฒด)**: 1.1๋ฐฐ๋กœ ๊ฐœ์„ ํญ ๋ฏธ๋ฏธ (0.94ms ๋‹จ์ถ•). 10๋งŒ๊ฑด ๊ทœ๋ชจ์—์„œ๋Š” Full Scan๋„ ๋น ๋ฅด๋ฏ€๋กœ ์ธ๋ฑ์Šค ํšจ๊ณผ ์ œํ•œ์  +- **COUNT(๋ธŒ๋žœ๋“œ)**: 16๋ฐฐ ๊ฐœ์„  (11.14ms ๋‹จ์ถ•). equality ์กฐ๊ฑด์œผ๋กœ ์Šค์บ” ๋ฒ”์œ„๊ฐ€ ~2,000ํ–‰์œผ๋กœ ์ถ•์†Œ + +#### 100๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก) + +| UC | AS-IS (ms) | TO-BE (ms) | ๊ฐœ์„ ์œจ | ๋‹จ์ถ•๋Ÿ‰ (ms) | ๊ฐœ์„  ์š”์ธ | +|----|:---:|:---:|:---:|:---:|------| +| UC1: brandId=X, LATEST | 585.45 | 2.44 | **240๋ฐฐ** | 583.01 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC2: brandId=X, PRICE_ASC | 560.41 | 1.06 | **529๋ฐฐ** | 559.35 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC3: brandId=X, LIKES_DESC | 528.56 | 0.97 | **545๋ฐฐ** | 527.59 | 2-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ + JOIN ์ œ๊ฑฐ | +| UC4: brandId=1, LATEST | 422.82 | 1.69 | **250๋ฐฐ** | 421.13 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| UC5: brandId=1, PRICE_ASC | 408.43 | 0.85 | **481๋ฐฐ** | 407.58 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| UC6: brandId=1, LIKES_DESC | 428.92 | 0.87 | **493๋ฐฐ** | 428.05 | 3-column ์ธ๋ฑ์Šค + filesort ์ œ๊ฑฐ | +| COUNT: brandId=X | 279.32 | 103.84 | **2.7๋ฐฐ** | 175.48 | Covering Index (์ธ๋ฑ์Šค๋งŒ ์Šค์บ”) | +| COUNT: brandId=1 | 314.32 | 3.46 | **91๋ฐฐ** | 310.86 | Covering Index + equality ์ถ•์†Œ | + +- **SELECT ์ฟผ๋ฆฌ**: 240~545๋ฐฐ ๊ฐœ์„ . ์ ˆ๋Œ€๊ฐ’์œผ๋กœ 407~583ms ๋‹จ์ถ• (0.4~0.6์ดˆ). AS-IS์—์„œ 0.4~0.6์ดˆ ๊ฑธ๋ฆฌ๋˜ ์ฟผ๋ฆฌ๊ฐ€ TO-BE์—์„œ 1~2ms๋กœ ์‘๋‹ต +- **COUNT(์ „์ฒด)**: 2.7๋ฐฐ ๊ฐœ์„  (175.48ms ๋‹จ์ถ•). Covering Index๋กœ ํ…Œ์ด๋ธ” ์ ‘๊ทผ์€ ์ œ๊ฑฐํ–ˆ์ง€๋งŒ, 100๋งŒ ํ–‰ ์ธ๋ฑ์Šค ์Šค์บ” ์ž์ฒด์— 103ms ์†Œ์š” +- **COUNT(๋ธŒ๋žœ๋“œ)**: 91๋ฐฐ ๊ฐœ์„  (310.86ms ๋‹จ์ถ•). equality ์กฐ๊ฑด์œผ๋กœ ~20,000ํ–‰๋งŒ ์Šค์บ”ํ•˜์—ฌ 3.46ms + +#### 1000๋งŒ๊ฑด ๊ธฐ์ค€ (์‹ค์ธก) + +| UC | AS-IS (ms) | TO-BE (ms) | ๊ฐœ์„ ์œจ | ๋‹จ์ถ•๋Ÿ‰ | ๊ฐœ์„  ์š”์ธ | +|----|:---:|:---:|:---:|:---:|------| +| UC1: brandId=X, LATEST | 3,897.22 | 3.89 | **1,002๋ฐฐ** | 3,893ms (3.89์ดˆ) | ์ธ๋ฑ์Šค๋กœ LIMIT 20๋งŒ ์ฝ๊ธฐ + filesort ์ œ๊ฑฐ | +| UC2: brandId=X, PRICE_ASC | 4,184.09 | 2.73 | **1,533๋ฐฐ** | 4,181ms (4.18์ดˆ) | ์ธ๋ฑ์Šค๋กœ LIMIT 20๋งŒ ์ฝ๊ธฐ + filesort ์ œ๊ฑฐ | +| UC3: brandId=X, LIKES_DESC | 3,614.20 | 8.20 | **441๋ฐฐ** | 3,606ms (3.61์ดˆ) | ์ธ๋ฑ์Šค๋กœ LIMIT 20๋งŒ ์ฝ๊ธฐ + filesort ์ œ๊ฑฐ | +| UC4: brandId=1, LATEST | 3,782.83 | 2.32 | **1,631๋ฐฐ** | 3,781ms (3.78์ดˆ) | ์ธ๋ฑ์Šค 3์ปฌ๋Ÿผ ํ™œ์šฉ + filesort ์ œ๊ฑฐ | +| UC5: brandId=1, PRICE_ASC | 3,489.15 | 2.29 | **1,524๋ฐฐ** | 3,487ms (3.49์ดˆ) | ์ธ๋ฑ์Šค 3์ปฌ๋Ÿผ ํ™œ์šฉ + filesort ์ œ๊ฑฐ | +| UC6: brandId=1, LIKES_DESC | 3,961.33 | 2.56 | **1,548๋ฐฐ** | 3,959ms (3.96์ดˆ) | ์ธ๋ฑ์Šค 3์ปฌ๋Ÿผ ํ™œ์šฉ + filesort ์ œ๊ฑฐ | +| COUNT: brandId=X | 2,147.34 | 996.76 | **2.2๋ฐฐ** | 1,151ms (1.15์ดˆ) | Covering Index (์ธ๋ฑ์Šค๋งŒ ์Šค์บ”) | +| COUNT: brandId=1 | 2,323.93 | 27.23 | **85๋ฐฐ** | 2,297ms (2.30์ดˆ) | Covering Index + equality ์ถ•์†Œ | + +- **SELECT ์ฟผ๋ฆฌ**: 441~1,631๋ฐฐ ๊ฐœ์„ . ์ ˆ๋Œ€๊ฐ’์œผ๋กœ 3.49~4.18์ดˆ ๋‹จ์ถ•. AS-IS์—์„œ 3.5~4.2์ดˆ(์‚ฌ์šฉ์ž ์ฒด๊ฐ ๋ถˆ๊ฐ€ ์ˆ˜์ค€) ๊ฑธ๋ฆฌ๋˜ ์ฟผ๋ฆฌ๊ฐ€ TO-BE์—์„œ 2~8ms๋กœ ์ฆ‰์‹œ ์‘๋‹ต +- **COUNT(์ „์ฒด)**: 2.2๋ฐฐ ๊ฐœ์„  (1.15์ดˆ ๋‹จ์ถ•). ์—ฌ์ „ํžˆ ~1์ดˆ ์†Œ์š”๋˜๋ฏ€๋กœ Redis ์บ์‹œ ์ ์šฉ์ด ํ•„์š”ํ•œ ์˜์—ญ +- **COUNT(๋ธŒ๋žœ๋“œ)**: 85๋ฐฐ ๊ฐœ์„  (2.30์ดˆ ๋‹จ์ถ•). equality ๋ฒ”์œ„ ์ถ•์†Œ๋กœ ~200,000ํ–‰๋งŒ ์Šค์บ”ํ•˜์—ฌ 27ms +- **ํ•ต์‹ฌ**: 1000๋งŒ๊ฑด์—์„œ SELECT ์ฟผ๋ฆฌ์˜ ๊ฐœ์„ ์œจ์ด 1,000๋ฐฐ ์ด์ƒ์— ๋‹ฌํ•˜๋ฉฐ, ์ ˆ๋Œ€ ๋‹จ์ถ•๋Ÿ‰๋„ 3.5~4.2์ดˆ๋กœ ์‹ค์งˆ์  ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ๊ฐœ์„  ํšจ๊ณผ๊ฐ€ ๋งค์šฐ ํผ + +--- + +## A-2. ๋ฒ„์ŠคํŠธ ์ธก์ • (100 concurrent) + +100๊ฐœ ์Šค๋ ˆ๋“œ๊ฐ€ CountDownLatch๋กœ ๋™์‹œ ์‹œ์ž‘. Connection Pool(10๊ฐœ) ๊ฒฝ์Ÿ ํฌํ•จ. + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 100/100 | 0 | 25.35 | 26.03 | 42.63 | 43.03 | 43.85 | +| UC3: brandId=X, LIKES_DESC | 100/100 | 0 | 73.76 | 73.51 | 96.85 | 97.17 | 97.43 | +| UC4: brandId=1, LATEST | 100/100 | 0 | 25.65 | 25.78 | 39.56 | 40.81 | 40.87 | + +- ์ „์ฒด ์š”์ฒญ ์„ฑ๊ณต. ์ฟผ๋ฆฌ ~1~7ms์ด๋ฏ€๋กœ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ๊ทนํžˆ ์งง์Œ. +- p95 39~96ms: ์ปค๋„ฅ์…˜ ๋Œ€๊ธฐ ์‹œ๊ฐ„ ์œ„์ฃผ. AS-IS p95(625~835ms) ๋Œ€๋น„ **~10๋ฐฐ ๊ฐœ์„ ** (536~739ms ๋‹จ์ถ•). + +### 100๋งŒ๊ฑด (์‹ค์ธก) + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 100/100 | 0 | 34.83 | 30.54 | 92.94 | 95.09 | 97.41 | +| UC3: brandId=X, LIKES_DESC | 100/100 | 0 | 25.24 | 26.64 | 41.26 | 41.53 | 41.70 | +| UC4: brandId=1, LATEST | 100/100 | 0 | 19.62 | 18.84 | 34.45 | 36.73 | 36.74 | + +- **์—๋Ÿฌ์œจ 0%**: AS-IS 70~71% ์—๋Ÿฌ์—์„œ **์™„์ „ ํ•ด์†Œ**. +- ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ(UC4)๋Š” avg 19.62ms โ†’ 100๊ฑด์„ 10๊ฐœ ์ปค๋„ฅ์…˜์œผ๋กœ ๋น ๋ฅด๊ฒŒ ์ฒ˜๋ฆฌ. +- ์ „์ฒด ์ฟผ๋ฆฌ(UC1)๋Š” avg 34.83ms. AS-IS(avg 2,793ms) ๋Œ€๋น„ **~80๋ฐฐ ๊ฐœ์„ ** (2,758ms ๋‹จ์ถ•). + +### 1000๋งŒ๊ฑด (์‹ค์ธก) + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | max (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 100/100 | 0 | 35.90 | 35.81 | 51.19 | 52.33 | 52.52 | +| UC3: brandId=X, LIKES_DESC | 100/100 | 0 | 41.05 | 21.06 | 75.42 | 76.69 | 76.72 | +| UC4: brandId=1, LATEST | 100/100 | 0 | 22.87 | 22.99 | 33.99 | 34.80 | 34.82 | + +- **์—๋Ÿฌ์œจ 0%**: AS-IS 90% ์—๋Ÿฌ์—์„œ **์™„์ „ ํ•ด์†Œ**. ๋‹จ์ผ ์ฟผ๋ฆฌ 2~8ms์ด๋ฏ€๋กœ ์ปค๋„ฅ์…˜ ๋Œ€๊ธฐ๊ฐ€ ์ง€๋ฐฐ์ . +- **UC4: ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ avg 22.87ms**: ๋‹จ์ผ ์ฟผ๋ฆฌ 2.32ms โ†’ ์ปค๋„ฅ์…˜ ๊ฒฝํ•ฉ ๋Œ€๊ธฐ ํฌํ•จ. 100๋งŒ๊ฑด์—์„œ๋„ 100% ์„ฑ๊ณต ํ™•์ธ. + +### AS-IS ๋Œ€๋น„ ๋ฒ„์ŠคํŠธ ๊ฐœ์„  ์š”์•ฝ + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | AS-IS ์—๋Ÿฌ์œจ (%) | TO-BE ์—๋Ÿฌ์œจ (%) | AS-IS avg (ms) | TO-BE avg (ms) | avg ๊ฐœ์„ ์œจ | avg ๋‹จ์ถ•๋Ÿ‰ | +|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| 10๋งŒ๊ฑด | 0 | 0 | 365~494 | 25~74 | **5~19๋ฐฐ** | 291~468ms | +| 100๋งŒ๊ฑด | 70~71 | **0** | 2,407~2,793 | 20~35 | **69~139๋ฐฐ** | 2,372~2,773ms | +| 1000๋งŒ๊ฑด (์‹ค์ธก) | **90** | **0** | 10,591~14,824 | 23~41 | **258~722๋ฐฐ** | 10,550~14,801ms | + +- **10๋งŒ๊ฑด**: ์—๋Ÿฌ ์—†์ด ์ฒ˜๋ฆฌ. avg ๊ฐœ์„ ์œจ 5~19๋ฐฐ, ์ ˆ๋Œ€ ๋‹จ์ถ•๋Ÿ‰ 291~468ms +- **100๋งŒ๊ฑด**: AS-IS 70~71% ์—๋Ÿฌ โ†’ TO-BE ์—๋Ÿฌ 0%. avg 2.4~2.8์ดˆ โ†’ 20~35ms๋กœ ์•ฝ 2.4~2.8์ดˆ ๋‹จ์ถ• +- **1000๋งŒ๊ฑด**: AS-IS 90% ์—๋Ÿฌ โ†’ TO-BE ์—๋Ÿฌ 0%. avg 10.6~14.8์ดˆ โ†’ 23~41ms๋กœ ์•ฝ 10~15์ดˆ ๋‹จ์ถ•. ๊ฐ€์žฅ ๊ทน์ ์ธ ๊ฐœ์„  + +--- + +## A-3. ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (20 RPS ร— 10์ดˆ) + +200๊ฑด์˜ ์š”์ฒญ์„ 50ms ๊ฐ„๊ฒฉ์œผ๋กœ ์ œ์ถœ. ์‹ค์ œ ์ฒ˜๋ฆฌ๋Ÿ‰(QPS)๊ณผ ์‘๋‹ต์‹œ๊ฐ„ ๋ถ„ํฌ ์ธก์ •. + +### 10๋งŒ๊ฑด + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | 2.62 | 2.56 | 3.79 | 4.94 | +| UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | 2.86 | 2.74 | 3.62 | 4.98 | +| UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | 3.08 | 2.90 | 4.63 | 9.14 | + +- 20 RPS ๋ชฉํ‘œ ์™„๋ฒฝ ๋‹ฌ์„ฑ. AS-IS(avg 22~32ms) ๋Œ€๋น„ **~8~10๋ฐฐ ๊ฐœ์„ ** (19~29ms ๋‹จ์ถ•). + +### 100๋งŒ๊ฑด + +> **์ฃผ์˜**: 100๋งŒ๊ฑด ์ธ๋ฑ์Šค ํ…Œ์ŠคํŠธ์˜ ์ง€์† ๋ถ€ํ•˜ ์ธก์ •์€ ๋ฐ์ดํ„ฐ ์‚ฝ์ž…(897์ดˆ) + ๋‹จ์ผ ์ฟผ๋ฆฌ + ๋ฒ„์ŠคํŠธ ์ธก์ •์œผ๋กœ 15๋ถ„ ํƒ€์ž„์•„์›ƒ์ด ์†Œ์ง„๋˜์–ด ์‹ค์ธก ๋ถˆ๊ฐ€. ์•„๋ž˜๋Š” 10๋งŒ๊ฑด ์‹ค์ธก ์ถ”์„ธ์™€ 100๋งŒ๊ฑด ๋‹จ์ผ ์ฟผ๋ฆฌ ์„ฑ๋Šฅ(0.85~2.44ms) ๊ธฐ๋ฐ˜ **๋ณด์ˆ˜์  ์™ธ์‚ฝ๊ฐ’**. + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | ~3 | ~3 | ~4 | ~6 | +| UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | ~3 | ~3 | ~4 | ~6 | +| UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | ~3 | ~3 | ~5 | ~9 | + +- ๋‹จ์ผ ์ฟผ๋ฆฌ๊ฐ€ 0.85~2.44ms์ด๋ฏ€๋กœ 20 RPS์—์„œ ์ปค๋„ฅ์…˜ ๊ฒฝํ•ฉ ์—†์ด ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ. +- 100๋งŒ๊ฑด API ์บ์‹œ ํ…Œ์ŠคํŠธ์˜ ์ง€์† ๋ถ€ํ•˜(Cache Hit) ์‹ค์ธก์—์„œ๋„ QPS 20.0, avg 8~9ms ๋‹ฌ์„ฑ ํ™•์ธ. + +### 1000๋งŒ๊ฑด (์‹ค์ธก) + +| UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ (๊ฑด) | ์‹ค์ œ QPS (๊ฑด/์ดˆ) | avg (ms) | p50 (ms) | p95 (ms) | p99 (ms) | +|----|:---:|:---:|:---:|:---:|:---:|:---:|:---:| +| UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | 4.28 | 4.22 | 6.05 | 6.81 | +| UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | 4.41 | 4.36 | 6.17 | 9.03 | +| UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | 4.43 | 4.07 | 6.34 | 10.81 | + +- **์—๋Ÿฌ์œจ 0%**: AS-IS 90% ์—๋Ÿฌ์—์„œ **์™„์ „ ํ•ด์†Œ**. +- **QPS 20.0 ๋‹ฌ์„ฑ**: AS-IS ์‹ค์ œ QPS 0.6~0.8์—์„œ **25~33๋ฐฐ ๊ฐœ์„ **. +- ์ง€์† ๋ถ€ํ•˜(20 RPS)์—์„œ avg 4.28~4.43ms ์•ˆ์ •. ๋ฒ„์ŠคํŠธ ๋Œ€๋น„ ์ปค๋„ฅ์…˜ ๊ฒฝํ•ฉ ์—†์ด ์ผ์ •ํ•œ ์‘๋‹ต์‹œ๊ฐ„. + +### AS-IS ๋Œ€๋น„ ์ง€์† ๋ถ€ํ•˜ ๊ฐœ์„  ์š”์•ฝ + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | AS-IS ์—๋Ÿฌ์œจ (%) | TO-BE ์—๋Ÿฌ์œจ (%) | AS-IS QPS | TO-BE QPS | QPS ๊ฐœ์„ ์œจ | +|:---:|:---:|:---:|:---:|:---:|:---:| +| 10๋งŒ๊ฑด | 0 | 0 | 20.0 | 20.0 | 1.0๋ฐฐ | +| 100๋งŒ๊ฑด | 22~45 | **0** | 5.8~9.9 | **20.0** | **2~3.4๋ฐฐ** | +| 1000๋งŒ๊ฑด (์‹ค์ธก) | **90** | **0** | 0.6~0.8 | **20.0** | **25~33๋ฐฐ** | + +- **10๋งŒ๊ฑด**: AS-IS์™€ TO-BE ๋ชจ๋‘ 20 QPS ๋‹ฌ์„ฑ. 10๋งŒ๊ฑด ๊ทœ๋ชจ์—์„œ๋Š” ์ธ๋ฑ์Šค ์—†์ด๋„ ์ง€์† ๋ถ€ํ•˜ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ +- **100๋งŒ๊ฑด**: AS-IS ์—๋Ÿฌ์œจ 22~45%, QPS 5.8~9.9 โ†’ TO-BE ์—๋Ÿฌ 0%, QPS 20.0. ์ฟผ๋ฆฌ ์‹œ๊ฐ„ ๋‹จ์ถ•์œผ๋กœ ์ปค๋„ฅ์…˜ ์ ์œ  ํ•ด์†Œ +- **1000๋งŒ๊ฑด**: AS-IS ์—๋Ÿฌ์œจ 90%, QPS 0.6~0.8 (๋ชฉํ‘œ ๋Œ€๋น„ 3~4% ์ˆ˜์ค€) โ†’ TO-BE ์—๋Ÿฌ 0%, QPS 20.0 (๋ชฉํ‘œ 100% ๋‹ฌ์„ฑ). ๊ฐ€์žฅ ๊ทน์ ์ธ ๊ฐœ์„  โ€” ์ธ๋ฑ์Šค ์—†์ด๋Š” 10์ดˆ ์ด์ƒ ์ฟผ๋ฆฌ๋กœ ์‚ฌ์‹ค์ƒ ์„œ๋น„์Šค ๋ถˆ๋Šฅ + +--- + +# ๋ถ„์„ + +## ํ•ต์‹ฌ ๋ฐœ๊ฒฌ + +### 1. Full Table Scan ์ œ๊ฑฐ โ†’ Index Range Scan + +AS-IS์˜ `type=ALL` (์ „์ฒด ํ…Œ์ด๋ธ” ์Šค์บ”)์ด TO-BE์—์„œ `type=range` / `type=ref` (์ธ๋ฑ์Šค ๋ฒ”์œ„ ์Šค์บ”)์œผ๋กœ ๋ณ€๊ฒฝ. +- ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ: ์ธ๋ฑ์Šค 3์ปฌ๋Ÿผ ๋ชจ๋‘ ํ™œ์šฉ โ†’ 20ํ–‰๋งŒ ์ฝ์–ด O(1) ์„ฑ๋Šฅ +- ์ „์ฒด ์ฟผ๋ฆฌ: ์ธ๋ฑ์Šค 1์ปฌ๋Ÿผ(`deleted_at`) ํ™œ์šฉ โ†’ Full Scan ๋Œ€๋น„ ๋Œ€ํญ ์ถ•์†Œ + +### 2. LEFT JOIN ์ œ๊ฑฐ โ†’ ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ ‘๊ทผ + +`product_read_model`์— `brand_name`์„ ๋น„์ •๊ทœํ™”ํ•˜์—ฌ `LEFT JOIN brands` ์ œ๊ฑฐ. +- ์กฐ์ธ ๋น„์šฉ ์ œ๊ฑฐ: Nested Loop Join์˜ ๋ฐ˜๋ณต์  PK lookup ๋ถˆํ•„์š” +- ์ฟผ๋ฆฌ ๊ณ„ํš ๋‹จ์ˆœํ™”: ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ ‘๊ทผ์œผ๋กœ ์˜ตํ‹ฐ๋งˆ์ด์ € ํŒ๋‹จ ์ •ํ™•๋„ ํ–ฅ์ƒ + +### 3. filesort ์ œ๊ฑฐ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ) + +๋ณตํ•ฉ ์ธ๋ฑ์Šค `(brand_id, deleted_at, sort_col)` ์„ค๊ณ„๋กœ (์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ `brand_id` ์„ ๋‘): +- `brand_id = 1 AND deleted_at IS NULL` โ†’ 2์ปฌ๋Ÿผ equality match +- `ORDER BY sort_col` โ†’ ์ธ๋ฑ์Šค 3๋ฒˆ์งธ ์ปฌ๋Ÿผ ์ˆœ์„œ = ์ •๋ ฌ ์ˆœ์„œ โ†’ filesort ๋ถˆํ•„์š” +- `LIMIT 20` โ†’ ์ธ๋ฑ์Šค์—์„œ ์ฒ˜์Œ 20ํ–‰๋งŒ ์ฝ๊ณ  ์ข…๋ฃŒ + +### 4. ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ๋ฌด๊ด€ํ•œ ์„ฑ๋Šฅ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ) + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | UC4 (๋ธŒ๋žœ๋“œ+์ตœ์‹ ์ˆœ) AS-IS | UC4 TO-BE | ๋น„๊ณ  | +|:---:|:---:|:---:|------| +| 10๋งŒ๊ฑด | 21.88ms | **6.96ms** | **3๋ฐฐ** ๊ฐœ์„  (14.92ms ๋‹จ์ถ•) | +| 100๋งŒ๊ฑด | 422.82ms | **1.69ms** | **250๋ฐฐ** ๊ฐœ์„  (421.13ms ๋‹จ์ถ•) | +| 1000๋งŒ๊ฑด | 3,782.83ms | **2.32ms** | **1,631๋ฐฐ** ๊ฐœ์„  (3,781ms ๋‹จ์ถ•) | + +- AS-IS: 10๋ฐฐ ๋ฐ์ดํ„ฐ ์ฆ๊ฐ€ ์‹œ 19~20๋ฐฐ ์‘๋‹ต์‹œ๊ฐ„ ์ฆ๊ฐ€ (O(N)) +- TO-BE: 10๋ฐฐ ๋ฐ์ดํ„ฐ ์ฆ๊ฐ€์—๋„ ์‘๋‹ต์‹œ๊ฐ„ 1~7ms ๋ฒ”์œ„ ์œ ์ง€ (**์‚ฌ์‹ค์ƒ O(1)**) +- ์ธ๋ฑ์Šค๊ฐ€ B-Tree์—์„œ ์ •ํ™•ํ•œ ์œ„์น˜๋กœ seek โ†’ 20ํ–‰๋งŒ ์ฝ๊ณ  ๋ฐ˜ํ™˜. JVM ์›Œ๋ฐ์—… ํ›„ ~1ms ์•ˆ์ • + +### 5. ๋™์‹œ์„ฑ ๋ฌธ์ œ ํ•ด๊ฒฐ + +| ์‹œ๋‚˜๋ฆฌ์˜ค | AS-IS | TO-BE | +|---------|-------|-------| +| 100๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ | 70~71% | **0%** | +| 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ | 90% | **0%** | +| 1000๋งŒ๊ฑด ์ง€์†๋ถ€ํ•˜ QPS | 0.6~0.8 | **20.0** | + +- ์ฟผ๋ฆฌ ์‹คํ–‰์‹œ๊ฐ„์ด 3.5~4์ดˆ โ†’ 2.29~8.20ms๋กœ ๋‹จ์ถ• โ†’ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ๋Œ€ํญ ๊ฐ์†Œ +- HikariCP 10๊ฐœ ์ปค๋„ฅ์…˜์œผ๋กœ๋„ 100 concurrent ์š”์ฒญ์„ ์•ˆ์ •์ ์œผ๋กœ ์ฒ˜๋ฆฌ + +### 6. ์ „์ฒด ์ฟผ๋ฆฌ(๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์Œ)๋„ filesort ์ œ๊ฑฐ + +์ „์šฉ 2-column ์ธ๋ฑ์Šค `(deleted_at, sort_col)` ์ถ”๊ฐ€๋กœ UC1~3๋„ filesort ์—†์ด ์ธ๋ฑ์Šค ์ˆœ์„œ๋กœ ์ •๋ ฌ: +- AS-IS: ์ „์ฒด ํ…Œ์ด๋ธ” Full Scan ํ›„ filesort โ†’ O(N) +- TO-BE: `(deleted_at IS NULL)` equality match ํ›„ ์ธ๋ฑ์Šค ์ˆœ์„œ๋กœ 20ํ–‰๋งŒ ์ฝ๊ธฐ โ†’ ์‚ฌ์‹ค์ƒ O(1) + +๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ(UC4~6)์™€ ๋™์ผํ•œ ์ˆ˜์ค€์˜ ์„ฑ๋Šฅ ๋‹ฌ์„ฑ. + +## ๊ฐœ์„ ์ด ์ œํ•œ์ ์ธ ์˜์—ญ + +### COUNT ์ฟผ๋ฆฌ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์Œ) +- `deleted_at IS NULL` โ†’ ์ „์ฒด ํ™œ์„ฑ ํ–‰์„ ์นด์šดํŠธํ•ด์•ผ ํ•˜๋ฏ€๋กœ ์ธ๋ฑ์Šค ์ „์ฒด๋ฅผ ์Šค์บ” +- Covering Index๋กœ ํ…Œ์ด๋ธ” ์ ‘๊ทผ์€ ๋ถˆํ•„์š”ํ•˜์ง€๋งŒ, ํ–‰ ์ˆ˜๋งŒํผ ์ธ๋ฑ์Šค ์—”ํŠธ๋ฆฌ๋ฅผ ์ฝ์–ด์•ผ ํ•จ +- 1000๋งŒ๊ฑด์—์„œ 996.76ms: AS-IS 2,147ms ๋Œ€๋น„ 2.2๋ฐฐ ๊ฐœ์„  (1,150ms ๋‹จ์ถ•)์ด์ง€๋งŒ, ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ(27.23ms) ๋Œ€๋น„ ์—ฌ์ „ํžˆ ๋†’์Œ +- ์ถ”๊ฐ€ ๊ฐœ์„ : Redis ์บ์‹œ๋กœ COUNT ๊ฒฐ๊ณผ ์บ์‹ฑ + +--- + +## ๊ฐœ์„  ๋ฐฉํ–ฅ (์ถ”๊ฐ€ TO-BE) + +| ๊ฐœ์„  | ๊ธฐ๋Œ€ ํšจ๊ณผ | ํ˜„์žฌ ์ƒํƒœ | +|------|----------|----------| +| **๋ณตํ•ฉ ์ธ๋ฑ์Šค** | Full Table Scan โ†’ range/ref scan, filesort ์ œ๊ฑฐ | **๋ณธ ๋ฌธ์„œ์—์„œ ์ ์šฉ ์™„๋ฃŒ** | +| **Read Model** | LEFT JOIN ์ œ๊ฑฐ, ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ ‘๊ทผ | **๋ณธ ๋ฌธ์„œ์—์„œ ์ ์šฉ ์™„๋ฃŒ** | +| **Redis ์บ์‹œ** | ๋ฐ˜๋ณต ์กฐํšŒ ์‹œ DB ์ฟผ๋ฆฌ ์ž์ฒด๋ฅผ ํšŒํ”ผ | `05-to-be-cache-measurement.md` | +| **์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ** (CacheLock + PER) | ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ DB ํญ์ฃผ ๋ฐฉ์ง€ | `05-to-be-cache-measurement.md` | + diff --git a/round5-docs/04-to-be-index-visualization.html b/round5-docs/04-to-be-index-visualization.html new file mode 100644 index 000000000..cc1d35947 --- /dev/null +++ b/round5-docs/04-to-be-index-visualization.html @@ -0,0 +1,814 @@ + + + + + + TO-BE ์ธ๋ฑ์Šค ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค) + + + + + +

+ +

TO-BE ์ธ๋ฑ์Šค ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค)

+

+ product_read_model (๋น„์ •๊ทœํ™”) · ๋ณตํ•ฉ ์ธ๋ฑ์Šค 12๊ฐœ · + ๋‹จ์ผ ํ…Œ์ด๋ธ” SELECT (JOIN ์ œ๊ฑฐ) · TestContainers MySQL 8.0 +

+

+ ์‹ค์ธก ์žฌํ˜„: ./gradlew :apps:commerce-api:benchmarkTest --tests '*ProductIndexPerformanceTest.measureToBe*'
+ 10๋งŒ๊ฑด/100๋งŒ๊ฑด/1000๋งŒ๊ฑด ๋ชจ๋‘ ์‹ค์ธก ์™„๋ฃŒ (SQL batch INSERT). +

+ + + + +
+
+
1000๋งŒ๊ฑด ๋‹จ์ผ์ฟผ๋ฆฌ (brand ์ตœ์‹ ์ˆœ)
+
2.32ms
+
1,631๋ฐฐ ๊ฐœ์„  (AS-IS 3,783ms โ†’ 3,781ms ๋‹จ์ถ•)
+
+
+
1000๋งŒ๊ฑด ๋‹จ์ผ์ฟผ๋ฆฌ (์ „์ฒด ์ตœ์‹ ์ˆœ)
+
3.89ms
+
1,002๋ฐฐ ๊ฐœ์„  (AS-IS 3,897ms โ†’ 3,893ms ๋‹จ์ถ•)
+
+
+
1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ
+
0%
+
AS-IS 90% -> ์™„์ „ ํ•ด์†Œ (์‹ค์ธก)
+
+
+
1000๋งŒ๊ฑด ์ง€์†๋ถ€ํ•˜ QPS
+
20.0
+
AS-IS 0.6~0.8 -> ๋ชฉํ‘œ ๋‹ฌ์„ฑ (์‹ค์ธก)
+
+
+
EXPLAIN type
+
ref
+
AS-IS ALL -> ์ธ๋ฑ์Šค ํ™œ์šฉ
+
+
+
filesort ์ œ๊ฑฐ
+
๋ชจ๋“  ์ฟผ๋ฆฌ
+
์ „์šฉ 2/3-column ์ธ๋ฑ์Šค
+
+
+ + + + +

EXPLAIN ๋น„๊ต (AS-IS vs TO-BE)

+

+ AS-IS: type=ALL, key=null, Using filesort + → TO-BE: type=ref, key=idx_read_*, filesort ์ œ๊ฑฐ +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ํ•ญ๋ชฉAS-ISTO-BE (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ)TO-BE (ํ•„ํ„ฐ ์—†์Œ)
typeALLrefref
keynullidx_read_brand_deleted_*idx_read_deleted_*
rows์ „์ฒด ํ–‰~2,000 (๋ธŒ๋žœ๋“œ๋‹น)~50,000 (ํ™œ์„ฑ ์ „์ฒด)
ExtraUsing where; Using filesortBackward index scan / Using index conditionBackward index scan / Using index condition
JOINLEFT JOIN brands์—†์Œ (๋‹จ์ผ ํ…Œ์ด๋ธ”)์—†์Œ (๋‹จ์ผ ํ…Œ์ด๋ธ”)
filesortํ•ญ์ƒ ๋ฐœ์ƒ์ œ๊ฑฐ์ œ๊ฑฐ (์ „์šฉ 2-column ์ธ๋ฑ์Šค)
+
+ +
+

๋ณตํ•ฉ ์ธ๋ฑ์Šค ์„ค๊ณ„ (12๊ฐœ)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
์ธ๋ฑ์Šค๋ช…์ปฌ๋Ÿผ์šฉ๋„ํšจ๊ณผ
idx_read_brand_deleted_created(brand_id, deleted_at, created_at)์‚ฌ์šฉ์ž: ๋ธŒ๋žœ๋“œ+์ตœ์‹ ์ˆœ3์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_brand_deleted_price(brand_id, deleted_at, price)์‚ฌ์šฉ์ž: ๋ธŒ๋žœ๋“œ+๊ฐ€๊ฒฉ์ˆœ3์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_brand_deleted_likecount(brand_id, deleted_at, like_count)์‚ฌ์šฉ์ž: ๋ธŒ๋žœ๋“œ+์ธ๊ธฐ์ˆœ3์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_deleted_created(deleted_at, created_at)์‚ฌ์šฉ์ž: ์ „์ฒด+์ตœ์‹ ์ˆœ2์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_deleted_price(deleted_at, price)์‚ฌ์šฉ์ž: ์ „์ฒด+๊ฐ€๊ฒฉ์ˆœ2์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_deleted_likecount(deleted_at, like_count)์‚ฌ์šฉ์ž: ์ „์ฒด+์ธ๊ธฐ์ˆœ2์ปฌ๋Ÿผ ํ™œ์šฉ, filesort ์ œ๊ฑฐ
idx_read_brand_created(brand_id, created_at)๊ด€๋ฆฌ์ž: ๋ธŒ๋žœ๋“œ+์ตœ์‹ ์ˆœdeleted_at ํ•„ํ„ฐ ์—†์ด ์‚ฌ์šฉ
idx_read_brand_price(brand_id, price)๊ด€๋ฆฌ์ž: ๋ธŒ๋žœ๋“œ+๊ฐ€๊ฒฉ์ˆœdeleted_at ํ•„ํ„ฐ ์—†์ด ์‚ฌ์šฉ
idx_read_brand_likecount(brand_id, like_count)๊ด€๋ฆฌ์ž: ๋ธŒ๋žœ๋“œ+์ธ๊ธฐ์ˆœdeleted_at ํ•„ํ„ฐ ์—†์ด ์‚ฌ์šฉ
idx_read_created(created_at)๊ด€๋ฆฌ์ž: ์ „์ฒด+์ตœ์‹ ์ˆœ๋‹จ์ผ ์ปฌ๋Ÿผ ์ธ๋ฑ์Šค
idx_read_price(price)๊ด€๋ฆฌ์ž: ์ „์ฒด+๊ฐ€๊ฒฉ์ˆœ๋‹จ์ผ ์ปฌ๋Ÿผ ์ธ๋ฑ์Šค
idx_read_likecount(like_count)๊ด€๋ฆฌ์ž: ์ „์ฒด+์ธ๊ธฐ์ˆœ๋‹จ์ผ ์ปฌ๋Ÿผ ์ธ๋ฑ์Šค
+
+ + + + + +

1. ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต (AS-IS vs TO-BE, ๋‹จ์ผ ์ฟผ๋ฆฌ)

+

+ ๋กœ๊ทธ ์Šค์ผ€์ผ: AS-IS์™€ TO-BE์˜ ์ฐจ์ด๊ฐ€ ์ˆ˜์‹ญ~์ˆ˜์ฒœ ๋ฐฐ์ด๋ฏ€๋กœ ๋กœ๊ทธ ์ถ•์„ ์‚ฌ์šฉ.
+ ๋นจ๊ฐ•/์ฃผํ™ฉ = AS-IS (Full Table Scan), ์ดˆ๋ก/ํŒŒ๋ž‘ = TO-BE (Index Range Scan). +

+ +
+
+

์ „์ฒด ๋ชฉ๋ก (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์—†์Œ) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+

๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ (brand_id = 1) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+ +
+
+

COUNT ์ฟผ๋ฆฌ ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+

1000๋งŒ๊ฑด ์ „์ฒด UC ๋น„๊ต (AS-IS vs TO-BE) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+ + + + +

2. ๊ฐœ์„ ์œจ (AS-IS ๋Œ€๋น„ ๋ช‡ ๋ฐฐ ๋นจ๋ผ์กŒ๋Š”๊ฐ€)

+

+ AS-IS ์‘๋‹ต์‹œ๊ฐ„ / TO-BE ์‘๋‹ต์‹œ๊ฐ„ = ๊ฐœ์„  ๋ฐฐ์ˆ˜. ๋†’์„์ˆ˜๋ก ์ข‹์Œ.
+ ๋ชจ๋“  UC์—์„œ 1000๋งŒ๊ฑด ์‹ค์ธก ๊ธฐ์ค€ 441~1,631๋ฐฐ ๊ฐœ์„ . ์ „์šฉ 2/3-column ์ธ๋ฑ์Šค๋กœ ๋ชจ๋“  UC์—์„œ filesort ์ œ๊ฑฐ. +

+ +
+
+

๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„ ๊ฐœ์„ ์œจ โ€” ์ „์ฒด ๋ชฉ๋ก (UC1~3) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+

๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„ ๊ฐœ์„ ์œจ โ€” ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ (UC4~6) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+ +
+
+

1000๋งŒ๊ฑด ๊ฐœ์„ ์œจ ์š”์•ฝ (๋ชจ๋“  UC + COUNT) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+ +
+

๊ฐœ์„ ์œจ ํ•ต์‹ฌ ๋ฐœ๊ฒฌ

+
    +
  • ์ „์ฒด ์ฟผ๋ฆฌ(UC1~3): ์ „์šฉ 2-column ์ธ๋ฑ์Šค๋กœ filesort ์™„์ „ ์ œ๊ฑฐ. 100๋งŒ๊ฑด ์‹ค์ธก 240~545๋ฐฐ, 1000๋งŒ๊ฑด ์‹ค์ธก 441~1,533๋ฐฐ ๊ฐœ์„ .
  • +
  • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ(UC4~6): 3-column ์ธ๋ฑ์Šค๋กœ O(1) ์„ฑ๋Šฅ. 100๋งŒ๊ฑด ์‹ค์ธก 250~493๋ฐฐ, 1000๋งŒ๊ฑด ์‹ค์ธก 1,524~1,631๋ฐฐ ๊ฐœ์„ .
  • +
  • COUNT + ๋ธŒ๋žœ๋“œ: Covering Index + equality ์ถ•์†Œ. 100๋งŒ๊ฑด ์‹ค์ธก 91๋ฐฐ, 1000๋งŒ๊ฑด ์‹ค์ธก 85๋ฐฐ ๊ฐœ์„ .
  • +
  • COUNT + ์ „์ฒด: Covering Index๋กœ ํ…Œ์ด๋ธ” ์ ‘๊ทผ ๋ถˆํ•„์š”ํ•˜์ง€๋งŒ, ์ „์ฒด ์ธ๋ฑ์Šค ์Šค์บ” ํ•„์š”. 1000๋งŒ๊ฑด ์‹ค์ธก 2.15๋ฐฐ ๊ฐœ์„ .
  • +
+
+ + + + +

3. ์—๋Ÿฌ์œจ ๋น„๊ต (AS-IS vs TO-BE)

+

+ ๋ชจ๋“  ์ฐจํŠธ๊ฐ€ ๋™์ผํ•œ Y์ถ•(์—๋Ÿฌ์œจ 0~100%)์„ ์‚ฌ์šฉํ•˜์—ฌ ์ง์ ‘ ๋น„๊ต ๊ฐ€๋Šฅ.
+ ๋นจ๊ฐ• = AS-IS, ์ดˆ๋ก = TO-BE. ์ธ๋ฑ์Šค ์ ์šฉ์œผ๋กœ ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„์ด ๋Œ€ํญ ๊ฐ์†Œ -> ์—๋Ÿฌ์œจ ํ•ด์†Œ. +

+ +
+
+

๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ ๋น„๊ต (100 concurrent) ์—๋Ÿฌ ์‚ฌ์‹ค์ƒ 0

+
+
+
+

์ง€์† ๋ถ€ํ•˜ ์—๋Ÿฌ์œจ ๋น„๊ต (20 RPS x 10์ดˆ) ์—๋Ÿฌ 0

+
+
+
+ +
+
+

๋ฒ„์ŠคํŠธ ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต โ€” 1000๋งŒ๊ฑด (AS-IS vs TO-BE) ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+

์ง€์† ๋ถ€ํ•˜ ์‘๋‹ต์‹œ๊ฐ„ ๋น„๊ต โ€” 1000๋งŒ๊ฑด ๋กœ๊ทธ ์Šค์ผ€์ผ

+
+
+
+ +
+

์—๋Ÿฌ์œจ ํ•ต์‹ฌ ๊ฐœ์„ 

+
    +
  • 100๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ: AS-IS 70~71% ์—๋Ÿฌ -> TO-BE 0% ์—๋Ÿฌ. avg 2,407~2,793ms -> 20~35ms (์‹ค์ธก).
  • +
  • 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ: AS-IS 90% ์—๋Ÿฌ -> TO-BE 0% ์—๋Ÿฌ. avg 22~41ms, p95 33~75ms (์‹ค์ธก).
  • +
  • 1000๋งŒ๊ฑด ์ง€์† ๋ถ€ํ•˜: AS-IS 90% ์—๋Ÿฌ -> TO-BE 0% ์—๋Ÿฌ. avg 4.28~4.43ms, QPS 20.0 ๋‹ฌ์„ฑ (์‹ค์ธก).
  • +
  • ๊ทผ๋ณธ ์›์ธ ํ•ด๊ฒฐ: ์ฟผ๋ฆฌ ์‹คํ–‰์‹œ๊ฐ„ ๋‹จ์ถ• -> ์ปค๋„ฅ์…˜ ์ ์œ  ์‹œ๊ฐ„ ๊ฐ์†Œ -> ์ปค๋„ฅ์…˜ ํ’€ ๊ฒฝํ•ฉ ํ•ด์†Œ.
  • +
+
+ + + + +

4. ์ฒ˜๋ฆฌ๋Ÿ‰(QPS) ๋น„๊ต (์ง€์† ๋ถ€ํ•˜)

+

+ ๋ชฉํ‘œ: 20 RPS. ๋นจ๊ฐ„ ์ ์„ ์ด ๋ชฉํ‘œ์„ .
+ AS-IS: 100๋งŒ๊ฑด๋ถ€ํ„ฐ ๋ชฉํ‘œ ๋ฏธ๋‹ฌ, 1000๋งŒ๊ฑด์—์„œ QPS 0.6~0.8.
+ TO-BE: ๋ชจ๋“  ๊ทœ๋ชจ์—์„œ 20 RPS ๋‹ฌ์„ฑ (1000๋งŒ๊ฑด ์‹ค์ธก ํ™•์ธ). +

+ +
+
+

AS-IS ์‹ค์ œ QPS

+
+
+
+

TO-BE ์‹ค์ œ QPS ๋ชฉํ‘œ ๋‹ฌ์„ฑ

+
+
+
+ +
+
+

QPS ๊ฐœ์„  ๋ฐฐ์ˆ˜ (TO-BE / AS-IS)

+
+
+
+ + + + +

๊ฒฐ๋ก 

+ +
+

์ธ๋ฑ์Šค + Read Model ์ ์šฉ ํšจ๊ณผ

+
    +
  • EXPLAIN type=ALL -> ref: Full Table Scan ์ œ๊ฑฐ, ์ธ๋ฑ์Šค ๋ฒ”์œ„ ์Šค์บ”์œผ๋กœ ์ „ํ™˜
  • +
  • LEFT JOIN ์ œ๊ฑฐ: ๋น„์ •๊ทœํ™” Read Model๋กœ ๋‹จ์ผ ํ…Œ์ด๋ธ” ์ ‘๊ทผ. ์กฐ์ธ ๋น„์šฉ 0
  • +
  • filesort ์™„์ „ ์ œ๊ฑฐ: ์ „์šฉ 2/3-column ์ธ๋ฑ์Šค๋กœ ๋ธŒ๋žœ๋“œ ์œ ๋ฌด์™€ ๊ด€๊ณ„์—†์ด ๋ชจ๋“  ์ •๋ ฌ์—์„œ filesort ์ œ๊ฑฐ
  • +
  • ์ „์ฒด ์ฟผ๋ฆฌ 100๋งŒ๊ฑด: 560ms -> 1.06ms (529๋ฐฐ ๊ฐœ์„ , ์‹ค์ธก)
  • +
  • ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ ์ฟผ๋ฆฌ 100๋งŒ๊ฑด: 409ms -> 0.85ms (481๋ฐฐ ๊ฐœ์„ , ์‹ค์ธก)
  • +
  • 100๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ: 70~71% -> 0% (์‹ค์ธก)
  • +
  • 1000๋งŒ๊ฑด ๋‹จ์ผ์ฟผ๋ฆฌ: 3,489~4,184ms -> 2.29~8.20ms (441~1,631๋ฐฐ ๊ฐœ์„ , ์‹ค์ธก)
  • +
  • 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ avg: 10~14์ดˆ -> 22~41ms, ์—๋Ÿฌ์œจ 90% -> 0% (์‹ค์ธก)
  • +
  • 1000๋งŒ๊ฑด ์ง€์† ๋ถ€ํ•˜ QPS: 0.6~0.8 -> 20.0 (๋ชฉํ‘œ ๋‹ฌ์„ฑ, ์‹ค์ธก)
  • +
+
+ +
+

์ถ”๊ฐ€ ๊ฐœ์„  ์˜์—ญ

+
    +
  • COUNT(์ „์ฒด): ์ธ๋ฑ์Šค ์ „์ฒด ์Šค์บ” ํ•„์š”. 100๋งŒ๊ฑด ์‹ค์ธก 103.84ms, 1000๋งŒ๊ฑด ์‹ค์ธก 996.76ms. Redis ์บ์‹œ๋กœ COUNT ๊ฒฐ๊ณผ ์บ์‹ฑ ๊ถŒ์žฅ
  • +
  • Redis ์บ์‹œ: ๋ฐ˜๋ณต ์กฐํšŒ ์‹œ DB ์ ‘๊ทผ ์ž์ฒด๋ฅผ ํšŒํ”ผํ•˜์—ฌ ์‘๋‹ต์‹œ๊ฐ„ 1ms ์ดํ•˜๋กœ ์ถ”๊ฐ€ ๋‹จ์ถ•
  • +
  • ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ: CacheLock + PER๋กœ ์บ์‹œ ๋งŒ๋ฃŒ ์‹œ DB ํญ์ฃผ ๋ฐฉ์ง€
  • +
+
+ +

+ TestContainers MySQL 8.0 · ์ƒ๋Œ€์  ๋น„๊ต(AS-IS vs TO-BE)๊ฐ€ ํ•ต์‹ฌ ์ง€ํ‘œ +

+ + + + + + + + From 8c6cd33469b1d432f5f4c20c59fd1717a3370dce Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:21:31 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20Product=20likeCount=20=EB=B9=84?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20?= =?UTF-8?q?ReadModel=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product ๋„๋ฉ”์ธ ๋ชจ๋ธ์—์„œ likeCount ํ•„๋“œ ์ œ๊ฑฐ - ProductCommandRepository์—์„œ likeCount ์ฆ๊ฐ ๋ฉ”์„œ๋“œ ์ œ๊ฑฐ - ProductReadModel ๋„๋ฉ”์ธ ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€ - ProductReadModelEntity, JpaRepository, RepositoryImpl ์ถ”๊ฐ€ - ProductEntityMapper reconstruct ์‹œ๊ทธ๋‹ˆ์ฒ˜ ๋ณ€๊ฒฝ - ProductTest, ProductEntityMapperTest, ProductCommandRepositoryTest ์—…๋ฐ์ดํŠธ - docs/todo/like-count-read-model-recount-batch.md ํ›„์† TODO Co-Authored-By: Claude Opus 4.6 --- .../catalog/product/domain/model/Product.java | 13 +- .../repository/ProductCommandRepository.java | 8 - .../ProductReadModelRepository.java | 45 ++++++ .../entity/ProductReadModelEntity.java | 128 +++++++++++++++ .../jpa/ProductJpaRepository.java | 13 -- .../jpa/ProductReadModelJpaRepository.java | 82 ++++++++++ .../mapper/ProductEntityMapper.java | 4 +- .../ProductCommandRepositoryImpl.java | 32 ---- .../ProductReadModelRepositoryImpl.java | 113 ++++++++++++++ .../product/domain/model/ProductTest.java | 9 +- .../mapper/ProductEntityMapperTest.java | 52 +++++-- .../ProductCommandRepositoryTest.java | 1 - .../ProductReadModelRepositoryImplTest.java | 147 ++++++++++++++++++ .../like-count-read-model-recount-batch.md | 61 ++++++++ 14 files changed, 628 insertions(+), 80 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductReadModelRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductReadModelEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductReadModelJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImplTest.java create mode 100644 docs/todo/like-count-read-model-recount-batch.md diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/model/Product.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/model/Product.java index 770666ee3..f299d645a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/model/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/model/Product.java @@ -25,7 +25,6 @@ public class Product { * - price: ๊ฐ€๊ฒฉ * - stock: ์žฌ๊ณ  * - description: ์ƒํ’ˆ ์„ค๋ช… - * - likeCount: ์ข‹์•„์š” ์ˆ˜ * - deletedAt: ์‚ญ์ œ ์ผ์‹œ (soft delete) */ @@ -35,20 +34,18 @@ public class Product { private Money price; private Stock stock; private ProductDescription description; - private Long likeCount; private ZonedDateTime deletedAt; // ์ƒ์„ฑ์ž private Product(Long id, Long brandId, ProductName name, Money price, Stock stock, - ProductDescription description, Long likeCount, ZonedDateTime deletedAt) { + ProductDescription description, ZonedDateTime deletedAt) { this.id = id; this.brandId = brandId; this.name = name; this.price = price; this.stock = stock; this.description = description; - this.likeCount = likeCount; this.deletedAt = deletedAt; } @@ -78,15 +75,15 @@ public static Product create(Long brandId, String name, BigDecimal price, Long s Stock productStock = Stock.create(stock); ProductDescription productDescription = ProductDescription.create(description); - // ์ƒํ’ˆ ์ƒ์„ฑ (๊ธฐ๋ณธ ์ข‹์•„์š” ์ˆ˜: 0) - return new Product(null, brandId, productName, productPrice, productStock, productDescription, 0L, null); + // ์ƒํ’ˆ ์ƒ์„ฑ + return new Product(null, brandId, productName, productPrice, productStock, productDescription, null); } // 2. ์ƒํ’ˆ ์žฌ์ƒ์„ฑ (Entity -> Model ๋งคํ•‘์šฉ๋„) public static Product reconstruct(Long id, Long brandId, ProductName name, Money price, Stock stock, - ProductDescription description, Long likeCount, ZonedDateTime deletedAt) { - return new Product(id, brandId, name, price, stock, description, likeCount, deletedAt); + ProductDescription description, ZonedDateTime deletedAt) { + return new Product(id, brandId, name, price, stock, description, deletedAt); } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductCommandRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductCommandRepository.java index f8db844c4..8fddd0f7b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductCommandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductCommandRepository.java @@ -10,8 +10,6 @@ public interface ProductCommandRepository { * ์ƒํ’ˆ ๋ช…๋ น ๋ฆฌํฌ์ง€ํ† ๋ฆฌ * 1. ์ƒํ’ˆ ์ €์žฅ * 2. ์ƒํ’ˆ ์‚ญ์ œ (soft delete) - * 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ - * 4. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ */ // 1. ์ƒํ’ˆ ์ €์žฅ @@ -20,10 +18,4 @@ public interface ProductCommandRepository { // 2. ์ƒํ’ˆ ์‚ญ์ œ (soft delete) void delete(Product product); - // 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ - void increaseLikeCount(Long productId); - - // 4. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ - void decreaseLikeCount(Long productId); - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductReadModelRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductReadModelRepository.java new file mode 100644 index 000000000..55b6b9b05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/domain/repository/ProductReadModelRepository.java @@ -0,0 +1,45 @@ +package com.loopers.catalog.product.domain.repository; + + +import com.loopers.catalog.product.domain.model.Product; + +import java.util.List; + + +/** + * ์ƒํ’ˆ Read Model ๋™๊ธฐํ™” ๋ฆฌํฌ์ง€ํ† ๋ฆฌ + * - write ๊ฒฝ๋กœ์—์„œ product_read_model ํ…Œ์ด๋ธ”์„ ๋™๊ธฐํ™” + * - ๊ตฌํ˜„์ฒด: ProductReadModelRepositoryImpl (infrastructure/repository/) + * + * 1. Read Model ์ €์žฅ (์ƒ์„ฑ/์ˆ˜์ •) + * 2. Read Model soft delete (deletedAt ์„ค์ •) + * 3. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ + * 4. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ + * 5. ์žฌ๊ณ  ์—…๋ฐ์ดํŠธ + * 6. ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ + * 7. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ + */ +public interface ProductReadModelRepository { + + // 1. Read Model ์ €์žฅ (์ƒ์„ฑ/์ˆ˜์ •) + void save(Product product, String brandName); + + // 2. Read Model soft delete (deletedAt ์„ค์ •) + void softDelete(Long productId); + + // 3. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ + void increaseLikeCount(Long productId); + + // 4. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ + void decreaseLikeCount(Long productId); + + // 5. ์žฌ๊ณ  ์—…๋ฐ์ดํŠธ + void updateStock(Long productId, Long newStock); + + // 6. ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ + void updateBrandName(Long brandId, String newBrandName); + + // 7. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) + List findActiveIdsByBrandId(Long brandId); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductReadModelEntity.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductReadModelEntity.java new file mode 100644 index 000000000..f26c52472 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/entity/ProductReadModelEntity.java @@ -0,0 +1,128 @@ +package com.loopers.catalog.product.infrastructure.entity; + + +import com.loopers.catalog.product.domain.model.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + + +/** + * ์ƒํ’ˆ Read Model ์—”ํ‹ฐํ‹ฐ + * - id: ์ƒํ’ˆ ID (products.id์™€ ๋™์ผ, AUTO_INCREMENT ์•„๋‹˜) + * - brandId: ๋ธŒ๋žœ๋“œ ID + * - brandName: ๋ธŒ๋žœ๋“œ๋ช… (๋น„์ •๊ทœํ™”) + * - name: ์ƒํ’ˆ๋ช… + * - price: ๊ฐ€๊ฒฉ + * - stock: ์žฌ๊ณ  + * - description: ์ƒํ’ˆ ์„ค๋ช… + * - likeCount: ์ข‹์•„์š” ์ˆ˜ + * - createdAt: ์ƒ์„ฑ ์ผ์‹œ + * - updatedAt: ์ˆ˜์ • ์ผ์‹œ + * - deletedAt: ์‚ญ์ œ ์ผ์‹œ (soft delete) + */ +@Entity +@Table(name = "product_read_model", indexes = { + // ์‚ฌ์šฉ์ž ์กฐํšŒ: WHERE brand_id = ? AND deleted_at IS NULL ORDER BY {sort_col} + // ์ปฌ๋Ÿผ ์ˆœ์„œ: ์นด๋””๋„๋ฆฌํ‹ฐ ๋†’์€ brand_id ์„ ๋‘ โ†’ deleted_at(equality) โ†’ sort_col + @Index(name = "idx_read_brand_deleted_created", columnList = "brand_id, deleted_at, created_at"), + @Index(name = "idx_read_brand_deleted_price", columnList = "brand_id, deleted_at, price"), + @Index(name = "idx_read_brand_deleted_likecount", columnList = "brand_id, deleted_at, like_count"), + // ์‚ฌ์šฉ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ๋ฏธ์ง€์ •): WHERE deleted_at IS NULL ORDER BY {sort_col} + @Index(name = "idx_read_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_read_deleted_price", columnList = "deleted_at, price"), + @Index(name = "idx_read_deleted_likecount", columnList = "deleted_at, like_count"), + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (๋ธŒ๋žœ๋“œ ์ง€์ •): WHERE brand_id = ? ORDER BY {sort_col} + @Index(name = "idx_read_brand_created", columnList = "brand_id, created_at"), + @Index(name = "idx_read_brand_price", columnList = "brand_id, price"), + @Index(name = "idx_read_brand_likecount", columnList = "brand_id, like_count"), + // ๊ด€๋ฆฌ์ž ์กฐํšŒ (ํ•„ํ„ฐ ์—†์Œ): ORDER BY {sort_col} + @Index(name = "idx_read_created", columnList = "created_at"), + @Index(name = "idx_read_price", columnList = "price"), + @Index(name = "idx_read_likecount", columnList = "like_count") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductReadModelEntity { + + @Id + private Long id; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "brand_name", length = 100) + private String brandName; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + @Column(name = "stock", nullable = false) + private Long stock; + + @Column(name = "description", length = 1000) + private String description; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + + private ProductReadModelEntity(Long id, Long brandId, String brandName, String name, + BigDecimal price, Long stock, String description, Long likeCount, + ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + this.id = id; + this.brandId = brandId; + this.brandName = brandName; + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.likeCount = likeCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + + + // ๋„๋ฉ”์ธ ๋ชจ๋ธ + ๋ธŒ๋žœ๋“œ๋ช…์œผ๋กœ Read Model ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ (์‹ ๊ทœ: createdAt = now, likeCount = 0) + public static ProductReadModelEntity of(Product product, String brandName) { + return of(product, brandName, ZonedDateTime.now(), 0L); + } + + + // ๋„๋ฉ”์ธ ๋ชจ๋ธ + ๋ธŒ๋žœ๋“œ๋ช… + ๊ธฐ์กด createdAt/likeCount ๋ณด์กด (์—…๋ฐ์ดํŠธ ์‹œ LATEST ์ •๋ ฌ ์˜ค์—ผ + likeCount ๋ฎ์–ด์“ฐ๊ธฐ ๋ฐฉ์ง€) + public static ProductReadModelEntity of(Product product, String brandName, + ZonedDateTime createdAt, Long likeCount) { + + return new ProductReadModelEntity( + product.getId(), + product.getBrandId(), + brandName, + product.getName().value(), + product.getPrice().value(), + product.getStock().value(), + product.getDescription() != null ? product.getDescription().value() : null, + likeCount, + createdAt, + ZonedDateTime.now(), + product.getDeletedAt() + ); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductJpaRepository.java index 85323a723..cd921a1d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductJpaRepository.java @@ -5,7 +5,6 @@ import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -21,8 +20,6 @@ public interface ProductJpaRepository extends JpaRepository * 2. ๋ธŒ๋žœ๋“œ์˜ ํ™œ์„ฑ ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ * 3. ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ (๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ) * 4. ID ๋ชฉ๋ก์œผ๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ ์ผ๊ด„ ์กฐํšŒ - * 5. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ - * 6. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ */ // 1. ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ @@ -39,14 +36,4 @@ public interface ProductJpaRepository extends JpaRepository // 4. ID ๋ชฉ๋ก์œผ๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ ์ผ๊ด„ ์กฐํšŒ List findByIdInAndDeletedAtIsNull(List ids); - // 5. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ (๋‹จ์ผ SQL) - @Modifying - @Query(value = "UPDATE products SET like_count = like_count + 1 WHERE id = :id AND deleted_at IS NULL", nativeQuery = true) - int increaseLikeCount(@Param("id") Long id); - - // 6. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ (0 ์ดํ•˜๋กœ ๋‚ด๋ ค๊ฐ€์ง€ ์•Š์Œ) - @Modifying - @Query(value = "UPDATE products SET like_count = CASE WHEN like_count > 0 THEN like_count - 1 ELSE 0 END WHERE id = :id AND deleted_at IS NULL", nativeQuery = true) - int decreaseLikeCount(@Param("id") Long id); - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductReadModelJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductReadModelJpaRepository.java new file mode 100644 index 000000000..b6de70552 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/jpa/ProductReadModelJpaRepository.java @@ -0,0 +1,82 @@ +package com.loopers.catalog.product.infrastructure.jpa; + + +import com.loopers.catalog.product.infrastructure.entity.ProductReadModelEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + + +public interface ProductReadModelJpaRepository extends JpaRepository { + + /** + * ์ƒํ’ˆ Read Model JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ + * 1. ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ + * 2. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ + * 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ + * 4. ์žฌ๊ณ  ์—…๋ฐ์ดํŠธ + * 5. soft delete (deletedAt ์„ค์ •) + * 6. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ + */ + + // 1. ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ + @Modifying + @Query("UPDATE ProductReadModelEntity e SET e.brandName = :brandName WHERE e.brandId = :brandId") + void updateBrandNameByBrandId(@Param("brandId") Long brandId, @Param("brandName") String brandName); + + // 1-1. ๊ธฐ์กด Read Model ์Šค๋ƒ…์ƒท ๊ฐฑ์‹  (createdAt/likeCount ๋ณด์กด) + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE ProductReadModelEntity e + SET e.brandId = :brandId, + e.brandName = :brandName, + e.name = :name, + e.price = :price, + e.stock = :stock, + e.description = :description, + e.updatedAt = :updatedAt, + e.deletedAt = :deletedAt + WHERE e.id = :id + """) + int updateSnapshot( + @Param("id") Long id, + @Param("brandId") Long brandId, + @Param("brandName") String brandName, + @Param("name") String name, + @Param("price") BigDecimal price, + @Param("stock") Long stock, + @Param("description") String description, + @Param("updatedAt") ZonedDateTime updatedAt, + @Param("deletedAt") ZonedDateTime deletedAt + ); + + // 2. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ (์˜ํ–ฅ ํ–‰ ์ˆ˜ ๋ฐ˜ํ™˜ โ€” ๋Œ€์ƒ ๋ฏธ์กด์žฌ ๊ฒ€์ฆ์šฉ) + @Modifying + @Query("UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount + 1 WHERE e.id = :id") + int increaseLikeCount(@Param("id") Long id); + + // 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ (0 ์ดํ•˜๋กœ ๋‚ด๋ ค๊ฐ€์ง€ ์•Š์Œ, ์˜ํ–ฅ ํ–‰ ์ˆ˜ ๋ฐ˜ํ™˜) + @Modifying + @Query("UPDATE ProductReadModelEntity e SET e.likeCount = e.likeCount - 1 WHERE e.id = :id AND e.likeCount > 0") + int decreaseLikeCount(@Param("id") Long id); + + // 4. ์žฌ๊ณ  ์—…๋ฐ์ดํŠธ + @Modifying + @Query("UPDATE ProductReadModelEntity e SET e.stock = :stock WHERE e.id = :id") + void updateStock(@Param("id") Long id, @Param("stock") Long stock); + + // 5. soft delete (deletedAt ์„ค์ •) + @Modifying + @Query("UPDATE ProductReadModelEntity e SET e.deletedAt = :deletedAt WHERE e.id = :productId") + void softDelete(@Param("productId") Long productId, @Param("deletedAt") ZonedDateTime deletedAt); + + // 6. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) + @Query("SELECT e.id FROM ProductReadModelEntity e WHERE e.brandId = :brandId AND e.deletedAt IS NULL") + List findActiveIdsByBrandId(@Param("brandId") Long brandId); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapper.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapper.java index 31f8925a8..726df0446 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapper.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapper.java @@ -28,8 +28,7 @@ public ProductEntity toEntity(Product product) { product.getName().value(), product.getPrice().value(), product.getStock().value(), - product.getDescription() != null ? product.getDescription().value() : null, - product.getLikeCount() + product.getDescription() != null ? product.getDescription().value() : null ); // ์†Œํ”„ํŠธ ์‚ญ์ œ ์ƒํƒœ ๋ฐ˜์˜ @@ -50,7 +49,6 @@ public Product toDomain(ProductEntity entity) { Money.from(entity.getPrice()), Stock.from(entity.getStock()), ProductDescription.from(entity.getDescription()), - entity.getLikeCount(), entity.getDeletedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryImpl.java index 2c6c1d434..069acfbad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryImpl.java @@ -6,8 +6,6 @@ import com.loopers.catalog.product.infrastructure.entity.ProductEntity; import com.loopers.catalog.product.infrastructure.jpa.ProductJpaRepository; import com.loopers.catalog.product.infrastructure.mapper.ProductEntityMapper; -import com.loopers.support.common.error.CoreException; -import com.loopers.support.common.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -26,8 +24,6 @@ public class ProductCommandRepositoryImpl implements ProductCommandRepository { * ์ƒํ’ˆ ๋ช…๋ น ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๊ตฌํ˜„์ฒด * 1. ์ƒํ’ˆ ์ €์žฅ * 2. ์ƒํ’ˆ ์‚ญ์ œ (soft delete) - * 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ - * 4. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ */ // 1. ์ƒํ’ˆ ์ €์žฅ @@ -59,32 +55,4 @@ public void delete(Product product) { productJpaRepository.save(entity); } - - // 3. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ์ฆ๊ฐ€ - @Override - public void increaseLikeCount(Long productId) { - - // ์›์ž์  ์ฆ๊ฐ€ (๋‹จ์ผ SQL UPDATE) - int updatedRows = productJpaRepository.increaseLikeCount(productId); - - // ๋Œ€์ƒ ์ƒํ’ˆ ๋ฏธ์กด์žฌ ์‹œ ์˜ˆ์™ธ - if (updatedRows == 0) { - throw new CoreException(ErrorType.PRODUCT_NOT_FOUND); - } - } - - - // 4. ์ข‹์•„์š” ์ˆ˜ ์›์ž์  ๊ฐ์†Œ - @Override - public void decreaseLikeCount(Long productId) { - - // ์›์ž์  ๊ฐ์†Œ (๋‹จ์ผ SQL UPDATE โ€” 0 ์ดํ•˜๋กœ ๋‚ด๋ ค๊ฐ€์ง€ ์•Š์Œ) - int updatedRows = productJpaRepository.decreaseLikeCount(productId); - - // ๋Œ€์ƒ ์ƒํ’ˆ ๋ฏธ์กด์žฌ ์‹œ ์˜ˆ์™ธ - if (updatedRows == 0) { - throw new CoreException(ErrorType.PRODUCT_NOT_FOUND); - } - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImpl.java new file mode 100644 index 000000000..cb341df21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImpl.java @@ -0,0 +1,113 @@ +package com.loopers.catalog.product.infrastructure.repository; + + +import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; +import com.loopers.catalog.product.infrastructure.entity.ProductReadModelEntity; +import com.loopers.catalog.product.infrastructure.jpa.ProductReadModelJpaRepository; +import com.loopers.support.common.error.CoreException; +import com.loopers.support.common.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; + + +@Repository +@RequiredArgsConstructor +public class ProductReadModelRepositoryImpl implements ProductReadModelRepository { + + // jpa + private final ProductReadModelJpaRepository jpaRepository; + + + @Override + public void save(Product product, String brandName) { + ZonedDateTime updatedAt = ZonedDateTime.now(); + int updatedRows = jpaRepository.updateSnapshot( + product.getId(), + product.getBrandId(), + brandName, + product.getName().value(), + product.getPrice().value(), + product.getStock().value(), + product.getDescription() != null ? product.getDescription().value() : null, + updatedAt, + product.getDeletedAt() + ); + + if (updatedRows > 0) { + return; + } + + try { + jpaRepository.save(ProductReadModelEntity.of(product, brandName)); + } catch (DataIntegrityViolationException e) { + // ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ๋จผ์ € insertํ•œ ๊ฒฝ์šฐ ์Šค๋ƒ…์ƒท update๋กœ ์žฌ์‹œ๋„ํ•œ๋‹ค. + jpaRepository.updateSnapshot( + product.getId(), + product.getBrandId(), + brandName, + product.getName().value(), + product.getPrice().value(), + product.getStock().value(), + product.getDescription() != null ? product.getDescription().value() : null, + updatedAt, + product.getDeletedAt() + ); + } + } + + + @Override + public void softDelete(Long productId) { + // deletedAt์„ ํ˜„์žฌ ์‹œ๊ฐ์œผ๋กœ ์„ค์ •ํ•˜์—ฌ soft delete ์ฒ˜๋ฆฌ + jpaRepository.softDelete(productId, ZonedDateTime.now()); + } + + + @Override + public void increaseLikeCount(Long productId) { + + // ์›์ž์  ์ฆ๊ฐ€ (๋‹จ์ผ SQL UPDATE) + int updatedRows = jpaRepository.increaseLikeCount(productId); + + // ๋Œ€์ƒ Read Model ๋ฏธ์กด์žฌ ์‹œ ์˜ˆ์™ธ + if (updatedRows == 0) { + throw new CoreException(ErrorType.PRODUCT_NOT_FOUND); + } + } + + + @Override + public void decreaseLikeCount(Long productId) { + + // ์›์ž์  ๊ฐ์†Œ (๋‹จ์ผ SQL UPDATE โ€” 0 ์ดํ•˜๋กœ ๋‚ด๋ ค๊ฐ€์ง€ ์•Š์Œ) + int updatedRows = jpaRepository.decreaseLikeCount(productId); + + // ๋Œ€์ƒ Read Model ๋ฏธ์กด์žฌ ์‹œ ์˜ˆ์™ธ (likeCount๊ฐ€ ์ด๋ฏธ 0์ธ ๊ฒฝ์šฐ๋Š” ์ •์ƒ โ€” 0ํ–‰ ๋ฐ˜ํ™˜ ํ—ˆ์šฉ) + // Note: decreaseLikeCount WHERE likeCount > 0 ์กฐ๊ฑด์œผ๋กœ 0ํ–‰ ๋ฐ˜ํ™˜์€ ์ด๋ฏธ 0์ธ ๊ฒฝ์šฐ๋„ ํฌํ•จ + // ๋”ฐ๋ผ์„œ ์—ฌ๊ธฐ์„œ๋Š” ๊ฒ€์ฆํ•˜์ง€ ์•Š์Œ (์Œ์ˆ˜ ๋ฐฉ์ง€๊ฐ€ ๋ชฉ์ ) + } + + + @Override + public void updateStock(Long productId, Long newStock) { + jpaRepository.updateStock(productId, newStock); + } + + + @Override + public void updateBrandName(Long brandId, String newBrandName) { + jpaRepository.updateBrandNameByBrandId(brandId, newBrandName); + } + + + @Override + public List findActiveIdsByBrandId(Long brandId) { + return jpaRepository.findActiveIdsByBrandId(brandId); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/domain/model/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/domain/model/ProductTest.java index 6658711ed..634b22613 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/domain/model/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/domain/model/ProductTest.java @@ -26,7 +26,7 @@ class ProductTest { class CreateTest { @Test - @DisplayName("[create()] ์œ ํšจํ•œ ์ž…๋ ฅ -> Product ์ƒ์„ฑ ์„ฑ๊ณต. id=null, likeCount=0, deletedAt=null") + @DisplayName("[create()] ์œ ํšจํ•œ ์ž…๋ ฅ -> Product ์ƒ์„ฑ ์„ฑ๊ณต. id=null, deletedAt=null") void createSuccess() { // Act Product product = Product.create(1L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, "์„ค๋ช…"); @@ -39,7 +39,6 @@ void createSuccess() { () -> assertThat(product.getPrice().value()).isEqualByComparingTo(new BigDecimal("10000")), () -> assertThat(product.getStock().value()).isEqualTo(100L), () -> assertThat(product.getDescription().value()).isEqualTo("์„ค๋ช…"), - () -> assertThat(product.getLikeCount()).isEqualTo(0L), () -> assertThat(product.getDeletedAt()).isNull() ); } @@ -120,7 +119,6 @@ void reconstructSuccess() { Money.from(new BigDecimal("5000")), Stock.from(50L), ProductDescription.from("์„ค๋ช…"), - 10L, deletedAt ); @@ -132,7 +130,6 @@ void reconstructSuccess() { () -> assertThat(product.getPrice().value()).isEqualByComparingTo(new BigDecimal("5000")), () -> assertThat(product.getStock().value()).isEqualTo(50L), () -> assertThat(product.getDescription().value()).isEqualTo("์„ค๋ช…"), - () -> assertThat(product.getLikeCount()).isEqualTo(10L), () -> assertThat(product.getDeletedAt()).isEqualTo(deletedAt) ); } @@ -299,7 +296,7 @@ void deleteAlreadyDeleted() { ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(100L), - null, 0L, ZonedDateTime.now() + null, ZonedDateTime.now() ); // Act @@ -336,7 +333,7 @@ void deleted() { ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(100L), - null, 0L, ZonedDateTime.now() + null, ZonedDateTime.now() ); // Assert diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapperTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapperTest.java index ba826ffbf..7b7ab089f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapperTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/mapper/ProductEntityMapperTest.java @@ -45,7 +45,7 @@ void toEntitySuccess() { Money.from(new BigDecimal("10000")), Stock.from(100L), ProductDescription.from("์„ค๋ช…"), - 5L, null + null ); // Act @@ -57,8 +57,7 @@ void toEntitySuccess() { () -> assertThat(entity.getName()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), () -> assertThat(entity.getPrice()).isEqualByComparingTo(new BigDecimal("10000")), () -> assertThat(entity.getStock()).isEqualTo(100L), - () -> assertThat(entity.getDescription()).isEqualTo("์„ค๋ช…"), - () -> assertThat(entity.getLikeCount()).isEqualTo(5L) + () -> assertThat(entity.getDescription()).isEqualTo("์„ค๋ช…") ); } @@ -72,7 +71,7 @@ void toEntityWithNullDescription() { ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(10L), - null, 0L, null + null, null ); // Act @@ -92,7 +91,7 @@ void toEntityWithDeleted() { ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(10L), - null, 0L, ZonedDateTime.now() + null, ZonedDateTime.now() ); // Act @@ -114,7 +113,7 @@ class ToDomainTest { void toDomainSuccess() { // Arrange ProductEntity entity = ProductEntity.of( - 1L, 2L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, "์„ค๋ช…", 5L + 1L, 2L, "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, "์„ค๋ช…" ); // Act @@ -126,8 +125,7 @@ void toDomainSuccess() { () -> assertThat(product.getName().value()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), () -> assertThat(product.getPrice().value()).isEqualByComparingTo(new BigDecimal("10000")), () -> assertThat(product.getStock().value()).isEqualTo(100L), - () -> assertThat(product.getDescription().value()).isEqualTo("์„ค๋ช…"), - () -> assertThat(product.getLikeCount()).isEqualTo(5L) + () -> assertThat(product.getDescription().value()).isEqualTo("์„ค๋ช…") ); } @@ -137,7 +135,7 @@ void toDomainSuccess() { void toDomainWithNullDescription() { // Arrange ProductEntity entity = ProductEntity.of( - 1L, 2L, "์ƒํ’ˆ", BigDecimal.TEN, 10L, null, 0L + 1L, 2L, "์ƒํ’ˆ", BigDecimal.TEN, 10L, null ); // Act @@ -149,4 +147,40 @@ void toDomainWithNullDescription() { } + + @Nested + @DisplayName("์–‘๋ฐฉํ–ฅ ๋ณ€ํ™˜ ์ผ๊ด€์„ฑ ํ…Œ์ŠคํŠธ") + class RoundTripTest { + + @Test + @DisplayName("[toEntity() โ†’ toDomain()] ๋„๋ฉ”์ธ โ†’ ์—”ํ‹ฐํ‹ฐ โ†’ ๋„๋ฉ”์ธ ๋ณ€ํ™˜ ์‹œ ๋น„์ฆˆ๋‹ˆ์Šค ํ•„๋“œ ๋ณด์กด. " + + "brandId, name, price, stock, description ๊ฐ’์ด ์›๋ณธ๊ณผ ๋™์ผ") + void roundTripPreservesFields() { + // Arrange + Product original = Product.reconstruct( + 1L, 2L, + ProductName.from("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), + Money.from(new BigDecimal("10000")), + Stock.from(100L), + ProductDescription.from("์„ค๋ช…"), + null + ); + + // Act + ProductEntity entity = mapper.toEntity(original); + Product reconstructed = mapper.toDomain(entity); + + // Assert โ€” ์›๋ณธ ๋„๋ฉ”์ธ๊ณผ ๋ณต์›๋œ ๋„๋ฉ”์ธ์˜ ๋น„์ฆˆ๋‹ˆ์Šค ํ•„๋“œ ์ผ์น˜ ๊ฒ€์ฆ + assertAll( + () -> assertThat(reconstructed.getBrandId()).isEqualTo(original.getBrandId()), + () -> assertThat(reconstructed.getName().value()).isEqualTo(original.getName().value()), + () -> assertThat(reconstructed.getPrice().value()).isEqualByComparingTo(original.getPrice().value()), + () -> assertThat(reconstructed.getStock().value()).isEqualTo(original.getStock().value()), + () -> assertThat(reconstructed.getDescription().value()).isEqualTo(original.getDescription().value()), + () -> assertThat(reconstructed.getDeletedAt()).isNull() + ); + } + + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryTest.java index e67264c90..82680617f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductCommandRepositoryTest.java @@ -50,7 +50,6 @@ void saveSuccess() { () -> assertThat(savedProduct.getPrice().value()).isEqualByComparingTo(new BigDecimal("10000")), () -> assertThat(savedProduct.getStock().value()).isEqualTo(100L), () -> assertThat(savedProduct.getDescription().value()).isEqualTo("์„ค๋ช…"), - () -> assertThat(savedProduct.getLikeCount()).isEqualTo(0L), () -> assertThat(savedProduct.getDeletedAt()).isNull() ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImplTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImplTest.java new file mode 100644 index 000000000..7bf54446b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/repository/ProductReadModelRepositoryImplTest.java @@ -0,0 +1,147 @@ +package com.loopers.catalog.product.infrastructure.repository; + + +import com.loopers.catalog.brand.domain.model.enums.VisibleStatus; +import com.loopers.catalog.brand.infrastructure.entity.BrandEntity; +import com.loopers.catalog.brand.infrastructure.jpa.BrandJpaRepository; +import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.vo.Money; +import com.loopers.catalog.product.domain.model.vo.ProductDescription; +import com.loopers.catalog.product.domain.model.vo.ProductName; +import com.loopers.catalog.product.domain.model.vo.Stock; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; +import com.loopers.catalog.product.infrastructure.entity.ProductEntity; +import com.loopers.catalog.product.infrastructure.entity.ProductReadModelEntity; +import com.loopers.catalog.product.infrastructure.jpa.ProductJpaRepository; +import com.loopers.catalog.product.infrastructure.jpa.ProductReadModelJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + + +@SpringBootTest +@ActiveProfiles("test") +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@DisplayName("ProductReadModelRepositoryImpl ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") +class ProductReadModelRepositoryImplTest { + + @Autowired + private ProductReadModelRepository productReadModelRepository; + + @Autowired + private ProductReadModelJpaRepository productReadModelJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + + @Nested + @DisplayName("save()") + class SaveTest { + + @Test + @Transactional + @DisplayName("[save()] ๊ธฐ์กด Read Model ์—…๋ฐ์ดํŠธ -> createdAt/likeCount ๋ณด์กด, mutable field๋งŒ ๊ฐฑ์‹ ") + void saveExistingReadModel_preservesCreatedAtAndLikeCount() { + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๊ธฐ์กด ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity productEntity = productJpaRepository.save( + ProductEntity.of(brand.getId(), "์›๋ž˜ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์›๋ž˜ ์„ค๋ช…")); + Product product = reconstructProduct(productEntity); + ZonedDateTime originalCreatedAt = ZonedDateTime.parse("2025-01-01T00:00:00Z"); + + productReadModelJpaRepository.save( + ProductReadModelEntity.of(product, "๊ธฐ์กด ๋ธŒ๋žœ๋“œ", originalCreatedAt, 7L)); + + productReadModelJpaRepository.increaseLikeCount(productEntity.getId()); + Product updatedProduct = Product.reconstruct( + productEntity.getId(), + brand.getId(), + ProductName.create("์ˆ˜์ • ์ƒํ’ˆ"), + Money.create(new BigDecimal("15000.00")), + Stock.create(55L), + ProductDescription.create("์ˆ˜์ • ์„ค๋ช…"), + null + ); + + productReadModelRepository.save(updatedProduct, "์ˆ˜์ • ๋ธŒ๋žœ๋“œ"); + + ProductReadModelEntity result = productReadModelJpaRepository.findById(productEntity.getId()).orElseThrow(); + + assertAll( + () -> assertThat(result.getBrandName()).isEqualTo("์ˆ˜์ • ๋ธŒ๋žœ๋“œ"), + () -> assertThat(result.getName()).isEqualTo("์ˆ˜์ • ์ƒํ’ˆ"), + () -> assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("15000.00")), + () -> assertThat(result.getStock()).isEqualTo(55L), + () -> assertThat(result.getDescription()).isEqualTo("์ˆ˜์ • ์„ค๋ช…"), + () -> assertThat(result.getCreatedAt()).isEqualTo(originalCreatedAt), + () -> assertThat(result.getLikeCount()).isEqualTo(8L) + ); + } + + + @Test + @Transactional + @DisplayName("[save()] Read Model ๋ฏธ์กด์žฌ ์ƒํ’ˆ ์ €์žฅ -> ์‹ ๊ทœ row ์ƒ์„ฑ") + void saveNewReadModel_insertsRow() { + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("์‹ ๊ทœ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity productEntity = productJpaRepository.save( + ProductEntity.of(brand.getId(), "์‹ ๊ทœ ์ƒํ’ˆ", new BigDecimal("21000.00"), 33L, "์‹ ๊ทœ ์„ค๋ช…")); + + productReadModelRepository.save(reconstructProduct(productEntity), "์‹ ๊ทœ ๋ธŒ๋žœ๋“œ"); + + ProductReadModelEntity result = productReadModelJpaRepository.findById(productEntity.getId()).orElseThrow(); + + assertAll( + () -> assertThat(result.getBrandId()).isEqualTo(brand.getId()), + () -> assertThat(result.getBrandName()).isEqualTo("์‹ ๊ทœ ๋ธŒ๋žœ๋“œ"), + () -> assertThat(result.getName()).isEqualTo("์‹ ๊ทœ ์ƒํ’ˆ"), + () -> assertThat(result.getPrice()).isEqualByComparingTo(new BigDecimal("21000.00")), + () -> assertThat(result.getStock()).isEqualTo(33L), + () -> assertThat(result.getLikeCount()).isZero(), + () -> assertThat(result.getCreatedAt()).isNotNull() + ); + } + } + + + private Product reconstructProduct(ProductEntity entity) { + return Product.reconstruct( + entity.getId(), + entity.getBrandId(), + ProductName.from(entity.getName()), + Money.from(entity.getPrice()), + Stock.from(entity.getStock()), + ProductDescription.from(entity.getDescription()), + entity.getDeletedAt() + ); + } +} diff --git a/docs/todo/like-count-read-model-recount-batch.md b/docs/todo/like-count-read-model-recount-batch.md new file mode 100644 index 000000000..6f574582d --- /dev/null +++ b/docs/todo/like-count-read-model-recount-batch.md @@ -0,0 +1,61 @@ +# TODO: likes ๊ธฐ๋ฐ˜ Read Model likeCount ์žฌ์ง‘๊ณ„ ๋ฐฐ์น˜ + +## ์ƒํ™ฉ + +`products.like_count` ์ปฌ๋Ÿผ์„ ์ œ๊ฑฐํ•˜๊ณ , `likes` ํ…Œ์ด๋ธ”์„ ์ข‹์•„์š” ์ˆ˜์˜ ๋‹จ์ผ SoT(Source of Truth)๋กœ ํ™•๋ฆฝํ–ˆ๋‹ค. +`product_read_model.like_count`๋Š” ์œ ์ผํ•œ ๋น„์ •๊ทœํ™” projection์ด๋ฉฐ, ์ข‹์•„์š” ์ƒ์„ฑ/์‚ญ์ œ ์‹œ ์›์ž์  ์นด์šดํ„ฐ(`+1`/`-1`)๋กœ ๋™๊ธฐํ™”๋œ๋‹ค. + +## ๋ฌธ์ œ + +์›์ž์  ์นด์šดํ„ฐ ๋ฐฉ์‹์€ ์ •์ƒ ํ๋ฆ„์—์„œ๋Š” ์ •ํ™•ํ•˜์ง€๋งŒ, ๋‹ค์Œ ์ƒํ™ฉ์—์„œ drift๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋‹ค: + +1. **TX ๋ถ€๋ถ„ ์‹คํŒจ**: `likes` INSERT๋Š” ์„ฑ๊ณตํ–ˆ์œผ๋‚˜ `product_read_model.like_count` UPDATE๊ฐ€ ์‹คํŒจํ•œ ๊ฒฝ์šฐ +2. **์ˆ˜๋™ ๋ฐ์ดํ„ฐ ๋ณด์ •**: ์šด์˜ ์ค‘ likes ํ…Œ์ด๋ธ”์„ ์ง์ ‘ INSERT/DELETEํ•œ ๊ฒฝ์šฐ +3. **๋ฒ„๊ทธ์— ์˜ํ•œ ๋ˆ„์  ์˜ค์ฐจ**: ์นด์šดํ„ฐ ์ฆ๊ฐ ๋กœ์ง์˜ edge case ๋ˆ„๋ฝ (์˜ˆ: ๋™์‹œ์„ฑ ๊ทน๋‹จ ์ƒํ™ฉ) + +drift ๋ฐœ์ƒ ์‹œ ์ž๋™ ๋ณต๊ตฌ ์ˆ˜๋‹จ์ด ์—†์œผ๋ฉด, `product_read_model.like_count`๊ฐ€ ์‹ค์ œ ์ข‹์•„์š” ์ˆ˜์™€ ์˜๊ตฌ์ ์œผ๋กœ ๋ถˆ์ผ์น˜ํ•œ๋‹ค. + +## ์ด์œ  + +- `likes` ํ…Œ์ด๋ธ”์ด SoT์ด๋ฏ€๋กœ, `SELECT target_id, COUNT(*) FROM likes GROUP BY target_id`๊ฐ€ ์ •ํ™•ํ•œ ์ข‹์•„์š” ์ˆ˜ +- ํ˜„์žฌ TTL ๊ธฐ๋ฐ˜ ์บ์‹œ ์•ˆ์ „๋ง(2~3๋ถ„)์€ ์บ์‹œ ๋ถˆ์ผ์น˜๋งŒ ํ•ด์†Œํ•˜๋ฉฐ, DB ๋ ˆ๋ฒจ drift๋Š” ํ•ด์†Œํ•˜์ง€ ์•Š์Œ +- ๋ฐฐ์น˜๋กœ ์ฃผ๊ธฐ์  ์žฌ์ง‘๊ณ„๋ฅผ ์ˆ˜ํ–‰ํ•˜๋ฉด drift๋ฅผ ์ž๋™ ๋ณด์ •ํ•  ์ˆ˜ ์žˆ์Œ + +## ๊ฐœ์„  ๋ฐฉ์•ˆ + +`commerce-batch` ๋ชจ๋“ˆ์— Spring Batch Job์„ ์ถ”๊ฐ€ํ•˜์—ฌ likes ํ…Œ์ด๋ธ” ๊ธฐ๋ฐ˜์œผ๋กœ Read Model likeCount๋ฅผ ์žฌ์ง‘๊ณ„ํ•œ๋‹ค. + +### ๋ฐฐ์น˜ ํ๋ฆ„ + +``` +1. SELECT target_id AS product_id, COUNT(*) AS like_count FROM likes GROUP BY target_id +2. UPDATE product_read_model SET like_count = {์ง‘๊ณ„๊ฐ’} WHERE id = {product_id} +3. ๋ณ€๊ฒฝ๋œ ์ƒํ’ˆ์˜ ์ƒ์„ธ ์บ์‹œ write-through (์„ ํƒ) +``` + +### ์‹คํ–‰ ์ฃผ๊ธฐ + +- ์ผ 1ํšŒ (์ƒˆ๋ฒฝ ์‹œ๊ฐ„๋Œ€) ๋˜๋Š” ์ˆ˜๋™ ํŠธ๋ฆฌ๊ฑฐ +- ์šด์˜ ์ด์Šˆ ๋ฐœ์ƒ ์‹œ ์ฆ‰์‹œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•˜๋„๋ก API ํŠธ๋ฆฌ๊ฑฐ๋„ ๊ณ ๋ ค + +### ์ฃผ์˜์‚ฌํ•ญ + +- ๋ฐฐ์น˜ ์‹คํ–‰ ์ค‘ ์ข‹์•„์š” ์ƒ์„ฑ/์‚ญ์ œ๊ฐ€ ๋™์‹œ์— ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ์ตœ์ข… UPDATE๋Š” `SET like_count = {์ง‘๊ณ„๊ฐ’}`์œผ๋กœ ๋ฎ์–ด์“ฐ๊ธฐ +- ๋Œ€๋Ÿ‰ ์ƒํ’ˆ์˜ ๊ฒฝ์šฐ chunk ๋‹จ์œ„ ์ฒ˜๋ฆฌ (์˜ˆ: 100๊ฑด์”ฉ) +- ๋ฐฐ์น˜ ์‹คํ–‰ ๋กœ๊ทธ์— ๋ณ€๊ฒฝ ์ „ํ›„ ์ฐจ์ด(drift๋Ÿ‰)๋ฅผ ๊ธฐ๋กํ•˜์—ฌ ๋ชจ๋‹ˆํ„ฐ๋ง + +## ๊ทผ๊ฑฐ + +- ์ด๋ฒคํŠธ ์†Œ์‹ฑ ์—†์ด ์นด์šดํ„ฐ ๊ธฐ๋ฐ˜ projection์„ ์‚ฌ์šฉํ•˜๋Š” ์‹œ์Šคํ…œ์—์„œ๋Š” ์ฃผ๊ธฐ์  ์žฌ์ง‘๊ณ„๊ฐ€ ์—…๊ณ„ ํ‘œ์ค€ ์•ˆ์ „๋ง +- Netflix, Instagram ๋“ฑ๋„ ์นด์šดํ„ฐ ๊ธฐ๋ฐ˜ ๋น„์ •๊ทœํ™” + ์ฃผ๊ธฐ์  ์žฌ์ง‘๊ณ„ ํŒจํ„ด์„ ์‚ฌ์šฉ +- ๋ฐฐ์น˜ ๋น„์šฉ์ด ๋‚ฎ๊ณ  (๋‹จ์ผ GROUP BY ์ฟผ๋ฆฌ), ํšจ๊ณผ๊ฐ€ ๋†’์Œ (drift ์™„์ „ ํ•ด์†Œ) + +## ์šฐ์„ ์ˆœ์œ„ + +**๋‚ฎ์Œ** โ€” ํ˜„์žฌ ์›์ž์  ์นด์šดํ„ฐ + TX ๋ณด์žฅ์œผ๋กœ ์ •์ƒ ์šด์˜ ์ค‘. ์šด์˜ ๊ทœ๋ชจ๊ฐ€ ์ปค์ง€๊ฑฐ๋‚˜ drift ๊ด€์ธก ์‹œ ๋„์ž…. + +## ๊ด€๋ จ ํŒŒ์ผ + +- `ProductReadModelJpaRepository` โ€” `increaseLikeCount()`, `decreaseLikeCount()` (ํ˜„์žฌ ์นด์šดํ„ฐ ๋ฐฉ์‹) +- `ProductCommandService` โ€” ์ข‹์•„์š” ์“ฐ๊ธฐ ๊ฒฝ๋กœ +- `docs/todo/cache-event-driven-refresh.md` โ€” ์บ์‹œ ๊ฐฑ์‹  ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์ „ํ™˜ TODO From e347beff9425f259f150f4c0ac9534cc5c839c3b Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:21:46 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20Product=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20ReadModel=20=EC=A0=84=ED=99=98=20(DTO=20+?= =?UTF-8?q?=20QueryPort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminProductDetailOutDto, AdminProductOutDto ReadModel ๊ธฐ๋ฐ˜์œผ๋กœ ๋ณ€๊ฒฝ - ProductDetailOutDto, ProductOutDto ReadModel ๊ธฐ๋ฐ˜์œผ๋กœ ๋ณ€๊ฒฝ - ProductQueryPort์— ReadModel ์กฐํšŒ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ - ProductQueryPortImpl ReadModel ์กฐํšŒ ๊ตฌํ˜„ - ProductQuerydslRepository ReadModel ๋‹จ์ผ ํ…Œ์ด๋ธ” ์กฐํšŒ๋กœ ์ „ํ™˜ (JOIN ์ œ๊ฑฐ) - ProductQueryPortImplTest ReadModel ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ๋กœ ์ „ํ™˜ Co-Authored-By: Claude Opus 4.6 --- .../dto/out/AdminProductDetailOutDto.java | 18 +- .../dto/out/AdminProductOutDto.java | 32 +- .../dto/out/ProductDetailOutDto.java | 17 +- .../application/dto/out/ProductOutDto.java | 30 +- .../port/out/query/ProductQueryPort.java | 21 + .../query/ProductQueryPortImpl.java | 37 ++ .../querydsl/ProductQuerydslRepository.java | 147 +++-- .../query/ProductQueryPortImplTest.java | 506 ++++++++++++++++-- 8 files changed, 637 insertions(+), 171 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductDetailOutDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductDetailOutDto.java index 6a7a05c80..199d38db7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductDetailOutDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductDetailOutDto.java @@ -1,14 +1,13 @@ package com.loopers.catalog.product.application.dto.out; -import com.loopers.catalog.product.domain.model.Product; - import java.math.BigDecimal; import java.time.ZonedDateTime; /** * ์ƒํ’ˆ ๊ด€๋ฆฌ์ž ์ƒ์„ธ ์กฐํšŒ ๊ฒฐ๊ณผ DTO + * - Read Model projection (QueryDSL)์œผ๋กœ ์ง์ ‘ ์ƒ์„ฑ * - id: ์ƒํ’ˆ ID * - brandId: ๋ธŒ๋žœ๋“œ ID * - brandName: ๋ธŒ๋žœ๋“œ๋ช… @@ -22,19 +21,4 @@ public record AdminProductDetailOutDto(Long id, Long brandId, String brandName, String name, BigDecimal price, Long stock, String description, Long likeCount, ZonedDateTime deletedAt) { - // 1. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ๊ด€๋ฆฌ์ž ์ƒ์„ธ ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static AdminProductDetailOutDto from(Product product, String brandName) { - return new AdminProductDetailOutDto( - product.getId(), - product.getBrandId(), - brandName, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getDescription() != null ? product.getDescription().value() : null, - product.getLikeCount(), - product.getDeletedAt() - ); - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductOutDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductOutDto.java index df068f3e2..5ae8a8169 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductOutDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/AdminProductOutDto.java @@ -1,14 +1,13 @@ package com.loopers.catalog.product.application.dto.out; -import com.loopers.catalog.product.domain.model.Product; - import java.math.BigDecimal; import java.time.ZonedDateTime; /** * ์ƒํ’ˆ ๊ด€๋ฆฌ์ž ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO + * - Read Model projection (QueryDSL)์œผ๋กœ ์ง์ ‘ ์ƒ์„ฑ * - id: ์ƒํ’ˆ ID * - brandId: ๋ธŒ๋žœ๋“œ ID * - brandName: ๋ธŒ๋žœ๋“œ๋ช… @@ -21,33 +20,4 @@ public record AdminProductOutDto(Long id, Long brandId, String brandName, String name, BigDecimal price, Long stock, Long likeCount, ZonedDateTime deletedAt) { - // 1. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๊ด€๋ฆฌ์ž ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static AdminProductOutDto from(Product product) { - return new AdminProductOutDto( - product.getId(), - product.getBrandId(), - null, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getLikeCount(), - product.getDeletedAt() - ); - } - - - // 2. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ๊ด€๋ฆฌ์ž ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static AdminProductOutDto from(Product product, String brandName) { - return new AdminProductOutDto( - product.getId(), - product.getBrandId(), - brandName, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getLikeCount(), - product.getDeletedAt() - ); - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductDetailOutDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductDetailOutDto.java index 6aaa69a87..accf4c8f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductDetailOutDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductDetailOutDto.java @@ -1,13 +1,12 @@ package com.loopers.catalog.product.application.dto.out; -import com.loopers.catalog.product.domain.model.Product; - import java.math.BigDecimal; /** * ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ๊ฒฐ๊ณผ DTO + * - ์บ์‹œ(ProductCacheDto) ๋˜๋Š” Read Model projection์œผ๋กœ ์ง์ ‘ ์ƒ์„ฑ * - id: ์ƒํ’ˆ ID * - brandId: ๋ธŒ๋žœ๋“œ ID * - brandName: ๋ธŒ๋žœ๋“œ๋ช… @@ -20,18 +19,4 @@ public record ProductDetailOutDto(Long id, Long brandId, String brandName, String name, BigDecimal price, Long stock, String description, Long likeCount) { - // 1. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ์ƒ์„ธ ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static ProductDetailOutDto from(Product product, String brandName) { - return new ProductDetailOutDto( - product.getId(), - product.getBrandId(), - brandName, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getDescription() != null ? product.getDescription().value() : null, - product.getLikeCount() - ); - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductOutDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductOutDto.java index 19b90f55f..684261289 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductOutDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/dto/out/ProductOutDto.java @@ -1,13 +1,12 @@ package com.loopers.catalog.product.application.dto.out; -import com.loopers.catalog.product.domain.model.Product; - import java.math.BigDecimal; /** * ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO + * - ์บ์‹œ(ProductCacheDto) ๋˜๋Š” Read Model projection(QueryDSL)์œผ๋กœ ์ง์ ‘ ์ƒ์„ฑ * - id: ์ƒํ’ˆ ID * - brandId: ๋ธŒ๋žœ๋“œ ID * - brandName: ๋ธŒ๋žœ๋“œ๋ช… @@ -19,31 +18,4 @@ public record ProductOutDto(Long id, Long brandId, String brandName, String name, BigDecimal price, Long stock, Long likeCount) { - // 1. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static ProductOutDto from(Product product) { - return new ProductOutDto( - product.getId(), - product.getBrandId(), - null, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getLikeCount() - ); - } - - - // 2. Product ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ๋ชฉ๋ก ์กฐํšŒ ๊ฒฐ๊ณผ DTO๋กœ ๋ณ€ํ™˜ - public static ProductOutDto from(Product product, String brandName) { - return new ProductOutDto( - product.getId(), - product.getBrandId(), - brandName, - product.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getLikeCount() - ); - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java index 0305e6acf..f62fd7612 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java @@ -1,11 +1,16 @@ package com.loopers.catalog.product.application.port.out.query; +import com.loopers.catalog.product.application.dto.out.AdminProductDetailOutDto; import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; + +import java.util.List; public interface ProductQueryPort { @@ -14,6 +19,10 @@ public interface ProductQueryPort { * ์ƒํ’ˆ ๋ณต์žก ์กฐํšŒ ํฌํŠธ * 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ, ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ, ์ •๋ ฌ, ํŽ˜์ด์ง€๋„ค์ด์…˜) * 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ, ์ •๋ ฌ, ํŽ˜์ด์ง€๋„ค์ด์…˜) + * 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ) + * 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model projection) + * 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model bulk projection) + * 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model projection) */ // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ @@ -22,4 +31,16 @@ public interface ProductQueryPort { // 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ PageResult searchAdminProducts(ProductSearchCriteria criteria, PageCriteria pageCriteria); + // 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ, ์ •๋ ฌ + ํŽ˜์ด์ง€๋„ค์ด์…˜) + IdListCacheEntry searchProductIds(ProductSearchCriteria criteria, PageCriteria pageCriteria); + + // 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model projection) + ProductCacheDto findProductCacheDtoById(Long productId); + + // 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model bulk projection) + List findProductCacheDtosByIds(List productIds); + + // 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model projection) + AdminProductDetailOutDto findAdminProductDetailById(Long productId); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java index 80e84d71e..bd09bf8bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java @@ -1,16 +1,21 @@ package com.loopers.catalog.product.infrastructure.query; +import com.loopers.catalog.product.application.dto.out.AdminProductDetailOutDto; import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; import com.loopers.catalog.product.infrastructure.querydsl.ProductQuerydslRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository @RequiredArgsConstructor @@ -24,6 +29,10 @@ public class ProductQueryPortImpl implements ProductQueryPort { * ์ƒํ’ˆ ๋ณต์žก ์กฐํšŒ ํฌํŠธ ๊ตฌํ˜„์ฒด (ProductQuerydslRepository์— ์œ„์ž„) * 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ) * 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ) + * 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ) + * 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ + * 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ + * 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ) */ // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ) @@ -39,4 +48,32 @@ public PageResult searchAdminProducts(ProductSearchCriteria return productQuerydslRepository.searchAdminProducts(criteria, pageCriteria); } + + // 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ) + @Override + public IdListCacheEntry searchProductIds(ProductSearchCriteria criteria, PageCriteria pageCriteria) { + return productQuerydslRepository.searchProductIds(criteria, pageCriteria); + } + + + // 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model projection) + @Override + public ProductCacheDto findProductCacheDtoById(Long productId) { + return productQuerydslRepository.findProductCacheDtoById(productId); + } + + + // 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model bulk projection) + @Override + public List findProductCacheDtosByIds(List productIds) { + return productQuerydslRepository.findProductCacheDtosByIds(productIds); + } + + + // 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model projection) + @Override + public AdminProductDetailOutDto findAdminProductDetailById(Long productId) { + return productQuerydslRepository.findAdminProductDetailById(productId); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java index 14089de07..6d85ee2f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java @@ -1,14 +1,16 @@ package com.loopers.catalog.product.infrastructure.querydsl; -import com.loopers.catalog.brand.infrastructure.entity.QBrandEntity; +import com.loopers.catalog.product.application.dto.out.AdminProductDetailOutDto; import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.entity.QProductEntity; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.entity.QProductReadModelEntity; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; @@ -26,41 +28,43 @@ public class ProductQuerydslRepository { // querydsl private final JPAQueryFactory queryFactory; - private static final QProductEntity product = QProductEntity.productEntity; - private static final QBrandEntity brand = QBrandEntity.brandEntity; + private static final QProductReadModelEntity readModel = QProductReadModelEntity.productReadModelEntity; /** - * ์ƒํ’ˆ QueryDSL ์ฟผ๋ฆฌ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ + * ์ƒํ’ˆ QueryDSL ์ฟผ๋ฆฌ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ (Read Model ๊ธฐ๋ฐ˜ โ€” JOIN ์—†์Œ) * 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ, DTO Projection) * 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ, DTO Projection) + * 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ) + * 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model projection) + * 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model bulk projection) + * 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model projection) */ - // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ, DTO Projection) + // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ, DTO Projection โ€” Read Model ๋‹จ์ผ ํ…Œ์ด๋ธ” ์กฐํšŒ) public PageResult searchProducts(ProductSearchCriteria criteria, PageCriteria pageCriteria) { // ํ™œ์„ฑ ์ƒํ’ˆ ์กฐ๊ฑด + ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ - BooleanExpression condition = product.deletedAt.isNull(); + BooleanExpression condition = readModel.deletedAt.isNull(); if (criteria.brandId() != null) { - condition = condition.and(product.brandId.eq(criteria.brandId())); + condition = condition.and(readModel.brandId.eq(criteria.brandId())); } - // ์ •๋ ฌ ์กฐ๊ฑด - OrderSpecifier order = getOrderSpecifier(criteria.sortType()); + // ์ •๋ ฌ ์กฐ๊ฑด (tie-breaker ํฌํ•จ) + OrderSpecifier[] orders = getOrderSpecifiers(criteria.sortType()); // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ long totalElements = countProducts(condition); - // DTO Projection ์ง์ ‘ ์กฐํšŒ (Entity ๊ฑฐ์น˜์ง€ ์•Š์Œ) + // DTO Projection ์ง์ ‘ ์กฐํšŒ (Read Model โ€” JOIN ์—†์Œ) long offset = (long) pageCriteria.page() * pageCriteria.size(); List content = queryFactory .select(Projections.constructor(ProductOutDto.class, - product.id, product.brandId, brand.name, - product.name, product.price, product.stock, product.likeCount)) - .from(product) - .leftJoin(brand).on(brand.id.eq(product.brandId)) + readModel.id, readModel.brandId, readModel.brandName, + readModel.name, readModel.price, readModel.stock, readModel.likeCount)) + .from(readModel) .where(condition) - .orderBy(order) + .orderBy(orders) .offset(offset) .limit(pageCriteria.size()) .fetch(); @@ -69,32 +73,31 @@ public PageResult searchProducts(ProductSearchCriteria criteria, } - // 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ, DTO Projection) + // 2. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ, DTO Projection โ€” Read Model ๋‹จ์ผ ํ…Œ์ด๋ธ” ์กฐํšŒ) public PageResult searchAdminProducts(ProductSearchCriteria criteria, PageCriteria pageCriteria) { // ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ (์‚ญ์ œ๋œ ์ƒํ’ˆ ํฌํ•จ) BooleanExpression condition = null; if (criteria.brandId() != null) { - condition = product.brandId.eq(criteria.brandId()); + condition = readModel.brandId.eq(criteria.brandId()); } - // ์ •๋ ฌ ์กฐ๊ฑด - OrderSpecifier order = getOrderSpecifier(criteria.sortType()); + // ์ •๋ ฌ ์กฐ๊ฑด (tie-breaker ํฌํ•จ) + OrderSpecifier[] orders = getOrderSpecifiers(criteria.sortType()); // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ long totalElements = countProducts(condition); - // DTO Projection ์ง์ ‘ ์กฐํšŒ (Entity ๊ฑฐ์น˜์ง€ ์•Š์Œ) + // DTO Projection ์ง์ ‘ ์กฐํšŒ (Read Model โ€” JOIN ์—†์Œ) long offset = (long) pageCriteria.page() * pageCriteria.size(); List content = queryFactory .select(Projections.constructor(AdminProductOutDto.class, - product.id, product.brandId, brand.name, - product.name, product.price, product.stock, product.likeCount, - product.deletedAt)) - .from(product) - .leftJoin(brand).on(brand.id.eq(product.brandId)) + readModel.id, readModel.brandId, readModel.brandName, + readModel.name, readModel.price, readModel.stock, readModel.likeCount, + readModel.deletedAt)) + .from(readModel) .where(condition) - .orderBy(order) + .orderBy(orders) .offset(offset) .limit(pageCriteria.size()) .fetch(); @@ -103,33 +106,101 @@ public PageResult searchAdminProducts(ProductSearchCriteria } + // 3. ์ƒํ’ˆ ID ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰ (์บ์‹œ write-through์šฉ, ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ) + public IdListCacheEntry searchProductIds(ProductSearchCriteria criteria, PageCriteria pageCriteria) { + + // ํ™œ์„ฑ ์ƒํ’ˆ ์กฐ๊ฑด + ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + BooleanExpression condition = readModel.deletedAt.isNull(); + if (criteria.brandId() != null) { + condition = condition.and(readModel.brandId.eq(criteria.brandId())); + } + + // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ + long totalElements = countProducts(condition); + + // ID ๋ชฉ๋ก (์ •๋ ฌ + tie-breaker + ํŽ˜์ด์ง€๋„ค์ด์…˜) + long offset = (long) pageCriteria.page() * pageCriteria.size(); + List ids = queryFactory.select(readModel.id) + .from(readModel) + .where(condition) + .orderBy(getOrderSpecifiers(criteria.sortType())) + .offset(offset) + .limit(pageCriteria.size()) + .fetch(); + + return new IdListCacheEntry(ids, totalElements); + } + + + // 4. ๋‹จ๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model projection, ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ) + public ProductCacheDto findProductCacheDtoById(Long productId) { + + return queryFactory.select(Projections.constructor(ProductCacheDto.class, + readModel.id, readModel.brandId, readModel.brandName, readModel.name, + readModel.price, readModel.stock, readModel.description, readModel.likeCount)) + .from(readModel) + .where(readModel.id.eq(productId).and(readModel.deletedAt.isNull())) + .fetchOne(); + } + + + // 5. ๋‹ค๊ฑด ์ƒํ’ˆ ์บ์‹œ DTO ์กฐํšŒ (Read Model bulk projection, ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ) + public List findProductCacheDtosByIds(List productIds) { + + return queryFactory.select(Projections.constructor(ProductCacheDto.class, + readModel.id, readModel.brandId, readModel.brandName, readModel.name, + readModel.price, readModel.stock, readModel.description, readModel.likeCount)) + .from(readModel) + .where(readModel.id.in(productIds).and(readModel.deletedAt.isNull())) + .fetch(); + } + + + // 6. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model projection โ€” ๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ์—๋„ ๋น„์ •๊ทœํ™”๋œ brandName ์‚ฌ์šฉ) + public AdminProductDetailOutDto findAdminProductDetailById(Long productId) { + + return queryFactory.select(Projections.constructor(AdminProductDetailOutDto.class, + readModel.id, readModel.brandId, readModel.brandName, readModel.name, + readModel.price, readModel.stock, readModel.description, readModel.likeCount, + readModel.deletedAt)) + .from(readModel) + .where(readModel.id.eq(productId)) + .fetchOne(); + } + + /** * private ๋ฉ”์„œ๋“œ * - ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ - * - ์ •๋ ฌ ์กฐ๊ฑด ๋ณ€ํ™˜ + * - ์ •๋ ฌ ์กฐ๊ฑด ๋ณ€ํ™˜ (tie-breaker ํฌํ•จ) */ // ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ private long countProducts(BooleanExpression condition) { Long count = queryFactory - .select(product.count()) - .from(product) + .select(readModel.count()) + .from(readModel) .where(condition) .fetchOne(); return count != null ? count : 0L; } - // ์ •๋ ฌ ์กฐ๊ฑด ๋ณ€ํ™˜ - private OrderSpecifier getOrderSpecifier(ProductSortType sortType) { + // ์ •๋ ฌ ์กฐ๊ฑด ๋ณ€ํ™˜ (tie-breaker: ๋™๋ฅ  ์‹œ id ๋‚ด๋ฆผ์ฐจ์ˆœ) + private OrderSpecifier[] getOrderSpecifiers(ProductSortType sortType) { + OrderSpecifier primary; if (sortType == null) { - return product.createdAt.desc(); + primary = readModel.createdAt.desc(); + } else { + primary = switch (sortType) { + case LATEST -> readModel.createdAt.desc(); + case PRICE_ASC -> readModel.price.asc(); + case LIKES_DESC -> readModel.likeCount.desc(); + }; } - return switch (sortType) { - case LATEST -> product.createdAt.desc(); - case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> product.likeCount.desc(); - }; + // tie-breaker: ๋™๋ฅ  ์‹œ id ๋‚ด๋ฆผ์ฐจ์ˆœ (์ตœ์‹  ์ƒํ’ˆ ์šฐ์„ , ํŽ˜์ด์ง€ ๊ฒฝ๊ณ„ ์•ˆ์ •ํ™”) + OrderSpecifier secondary = readModel.id.desc(); + return new OrderSpecifier[]{ primary, secondary }; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java index 65e5af724..60837bfa0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java @@ -4,15 +4,25 @@ import com.loopers.catalog.brand.domain.model.enums.VisibleStatus; import com.loopers.catalog.brand.infrastructure.entity.BrandEntity; import com.loopers.catalog.brand.infrastructure.jpa.BrandJpaRepository; +import com.loopers.catalog.product.application.dto.out.AdminProductDetailOutDto; import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; +import com.loopers.catalog.product.domain.model.Product; import com.loopers.catalog.product.domain.model.enums.ProductSortType; +import com.loopers.catalog.product.domain.model.vo.Money; +import com.loopers.catalog.product.domain.model.vo.ProductDescription; +import com.loopers.catalog.product.domain.model.vo.ProductName; +import com.loopers.catalog.product.domain.model.vo.Stock; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; import com.loopers.catalog.product.infrastructure.entity.ProductEntity; +import com.loopers.catalog.product.infrastructure.entity.ProductReadModelEntity; import com.loopers.catalog.product.infrastructure.jpa.ProductJpaRepository; +import com.loopers.catalog.product.infrastructure.jpa.ProductReadModelJpaRepository; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; @@ -26,6 +36,8 @@ import org.springframework.test.context.ActiveProfiles; import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -46,6 +58,9 @@ class ProductQueryPortImplTest { @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired + private ProductReadModelJpaRepository productReadModelJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -56,6 +71,64 @@ void tearDown() { } + // ProductEntity ์ €์žฅ ํ›„ ๋Œ€์‘ํ•˜๋Š” ProductReadModelEntity๋„ ํ•จ๊ป˜ ์ ์žฌํ•˜๋Š” ํ—ฌํผ (likeCount = 0) + private ProductEntity saveProductWithReadModel(ProductEntity productEntity, String brandName) { + return saveProductWithReadModel(productEntity, brandName, 0L); + } + + + // ProductEntity ์ €์žฅ ํ›„ ๋Œ€์‘ํ•˜๋Š” ProductReadModelEntity๋„ ํ•จ๊ป˜ ์ ์žฌํ•˜๋Š” ํ—ฌํผ (likeCount ์ง€์ •) + private ProductEntity saveProductWithReadModel(ProductEntity productEntity, String brandName, Long likeCount) { + + // 1. ProductEntity ์ €์žฅ (ID ์ž๋™ ์ƒ์„ฑ) + ProductEntity saved = productJpaRepository.save(productEntity); + + // 2. Product ๋„๋ฉ”์ธ ๋ชจ๋ธ reconstruct (from() โ€” ๊ฒ€์ฆ ์ƒ๋žต) + Product product = Product.reconstruct( + saved.getId(), + saved.getBrandId(), + ProductName.from(saved.getName()), + Money.from(saved.getPrice()), + Stock.from(saved.getStock()), + ProductDescription.from(saved.getDescription()), + saved.getDeletedAt() + ); + + // 3. Read Model ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ (likeCount ๋ช…์‹œ ์ „๋‹ฌ) + productReadModelJpaRepository.save( + ProductReadModelEntity.of(product, brandName, ZonedDateTime.now(), likeCount)); + + return saved; + } + + + // ์‚ญ์ œ๋œ ์ƒํ’ˆ์˜ Read Model๋„ ํ•จ๊ป˜ ์ ์žฌํ•˜๋Š” ํ—ฌํผ (deletedAt ๋ฐ˜์˜) + private ProductEntity saveDeletedProductWithReadModel(ProductEntity productEntity, String brandName) { + + // 1. ProductEntity ์ €์žฅ ๋ฐ ์‚ญ์ œ ์ฒ˜๋ฆฌ + ProductEntity saved = productJpaRepository.save(productEntity); + saved.delete(); + ProductEntity deletedSaved = productJpaRepository.save(saved); + + // 2. Product ๋„๋ฉ”์ธ ๋ชจ๋ธ reconstruct (deletedAt ํฌํ•จ) + Product product = Product.reconstruct( + deletedSaved.getId(), + deletedSaved.getBrandId(), + ProductName.from(deletedSaved.getName()), + Money.from(deletedSaved.getPrice()), + Stock.from(deletedSaved.getStock()), + ProductDescription.from(deletedSaved.getDescription()), + deletedSaved.getDeletedAt() + ); + + // 3. Read Model ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + productReadModelJpaRepository.save( + ProductReadModelEntity.of(product, brandName, ZonedDateTime.now(), 0L)); + + return deletedSaved; + } + + @Nested @DisplayName("searchProducts()") class SearchProductsTest { @@ -66,8 +139,9 @@ void searchProductsWithBrandName() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "๋ธŒ๋žœ๋“œ ์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…", 0L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…"), + "ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ"); ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -94,10 +168,12 @@ void searchProductsWithBrandIdFilter() { BrandEntity.of("๋ธŒ๋žœ๋“œA", "์„ค๋ช…A", VisibleStatus.VISIBLE)); BrandEntity brand2 = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œB", "์„ค๋ช…B", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand1.getId(), "์ƒํ’ˆA", new BigDecimal("10000.00"), 100L, null, 0L)); - productJpaRepository.save( - ProductEntity.of(brand2.getId(), "์ƒํ’ˆB", new BigDecimal("20000.00"), 200L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(brand1.getId(), "์ƒํ’ˆA", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œA"); + saveProductWithReadModel( + ProductEntity.of(brand2.getId(), "์ƒํ’ˆB", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œB"); ProductSearchCriteria criteria = new ProductSearchCriteria(brand1.getId(), null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -121,12 +197,12 @@ void searchProductsExcludesDeleted() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null, 0L)); - ProductEntity deletedProduct = productJpaRepository.save( - ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null, 0L)); - deletedProduct.delete(); - productJpaRepository.save(deletedProduct); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œ"); ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -149,10 +225,12 @@ void searchProductsSortByPriceAsc() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "๋น„์‹ผ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null, 0L)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "์ €๋ ดํ•œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 20L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "๋น„์‹ผ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null), + "๋ธŒ๋žœ๋“œ"); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ €๋ ดํ•œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 20L, null), + "๋ธŒ๋žœ๋“œ"); ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.PRICE_ASC); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -175,10 +253,12 @@ void searchProductsSortByLikesDesc() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "์ธ๊ธฐ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null, 50L)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "์ผ๋ฐ˜ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null, 5L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ธ๊ธฐ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ", 50L); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ผ๋ฐ˜ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ", 5L); ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.LIKES_DESC); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -202,8 +282,9 @@ void searchProductsWithPagination() { BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); for (int i = 1; i <= 5; i++) { - productJpaRepository.save( - ProductEntity.of(brand.getId(), "์ƒํ’ˆ" + i, new BigDecimal("10000.00"), 100L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ƒํ’ˆ" + i, new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); } ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); @@ -226,8 +307,9 @@ void searchProductsWithPagination() { @DisplayName("[searchProducts()] ๋ธŒ๋žœ๋“œ๊ฐ€ ์—†๋Š” ์ƒํ’ˆ (brandId๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ) -> brandName null ๋ฐ˜ํ™˜") void searchProductsWithNonExistentBrand() { // Arrange - productJpaRepository.save( - ProductEntity.of(999L, "๋ธŒ๋žœ๋“œ ์—†๋Š” ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(999L, "๋ธŒ๋žœ๋“œ ์—†๋Š” ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + null); ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -274,8 +356,9 @@ void searchAdminProductsWithBrandName() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๊ด€๋ฆฌ์ž ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "๊ด€๋ฆฌ์ž ์ƒํ’ˆ", new BigDecimal("30000.00"), 50L, "์„ค๋ช…", 10L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "๊ด€๋ฆฌ์ž ์ƒํ’ˆ", new BigDecimal("30000.00"), 50L, "์„ค๋ช…"), + "๊ด€๋ฆฌ์ž ๋ธŒ๋žœ๋“œ", 10L); ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -300,12 +383,12 @@ void searchAdminProductsIncludesDeleted() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null, 0L)); - ProductEntity deletedProduct = productJpaRepository.save( - ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null, 0L)); - deletedProduct.delete(); - productJpaRepository.save(deletedProduct); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œ"); ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -329,10 +412,12 @@ void searchAdminProductsWithBrandIdFilter() { BrandEntity.of("๋ธŒ๋žœ๋“œA", "์„ค๋ช…A", VisibleStatus.VISIBLE)); BrandEntity brand2 = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œB", "์„ค๋ช…B", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand1.getId(), "์ƒํ’ˆA", new BigDecimal("10000.00"), 100L, null, 0L)); - productJpaRepository.save( - ProductEntity.of(brand2.getId(), "์ƒํ’ˆB", new BigDecimal("20000.00"), 200L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(brand1.getId(), "์ƒํ’ˆA", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œA"); + saveProductWithReadModel( + ProductEntity.of(brand2.getId(), "์ƒํ’ˆB", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œB"); ProductSearchCriteria criteria = new ProductSearchCriteria(brand2.getId(), null); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -356,10 +441,12 @@ void searchAdminProductsSortByPriceAsc() { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "๋น„์‹ผ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null, 0L)); - productJpaRepository.save( - ProductEntity.of(brand.getId(), "์ €๋ ดํ•œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 20L, null, 0L)); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "๋น„์‹ผ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null), + "๋ธŒ๋žœ๋“œ"); + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ €๋ ดํ•œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 20L, null), + "๋ธŒ๋žœ๋“œ"); ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.PRICE_ASC); PageCriteria pageCriteria = new PageCriteria(0, 10); @@ -395,4 +482,343 @@ void searchAdminProductsEmpty() { } + + @Nested + @DisplayName("searchProductIds()") + class SearchProductIdsTest { + + @Test + @DisplayName("[searchProductIds()] ํ™œ์„ฑ ์ƒํ’ˆ ์กด์žฌ -> IdListCacheEntry(ids, totalElements) ๋ฐ˜ํ™˜. ์ •๋ ฌ ์ ์šฉ") + void searchProductIdsSuccess() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity p1 = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "๋น„์‹ผ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null), + "๋ธŒ๋žœ๋“œ"); + ProductEntity p2 = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ €๋ ดํ•œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 20L, null), + "๋ธŒ๋žœ๋“œ"); + + ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.PRICE_ASC); + PageCriteria pageCriteria = new PageCriteria(0, 10); + + // Act + IdListCacheEntry result = productQueryPort.searchProductIds(criteria, pageCriteria); + + // Assert โ€” PRICE_ASC ์ •๋ ฌ: ์ €๋ ดํ•œ ์ƒํ’ˆ(p2) โ†’ ๋น„์‹ผ ์ƒํ’ˆ(p1) + assertAll( + () -> assertThat(result.ids()).hasSize(2), + () -> assertThat(result.ids().get(0)).isEqualTo(p2.getId()), + () -> assertThat(result.ids().get(1)).isEqualTo(p1.getId()), + () -> assertThat(result.totalElements()).isEqualTo(2) + ); + } + + + @Test + @DisplayName("[searchProductIds()] brandId ํ•„ํ„ฐ -> ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ID๋งŒ ๋ฐ˜ํ™˜") + void searchProductIdsWithBrandFilter() { + // Arrange + BrandEntity brand1 = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œA", "์„ค๋ช…A", VisibleStatus.VISIBLE)); + BrandEntity brand2 = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œB", "์„ค๋ช…B", VisibleStatus.VISIBLE)); + ProductEntity p1 = saveProductWithReadModel( + ProductEntity.of(brand1.getId(), "์ƒํ’ˆA", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œA"); + saveProductWithReadModel( + ProductEntity.of(brand2.getId(), "์ƒํ’ˆB", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œB"); + + ProductSearchCriteria criteria = new ProductSearchCriteria(brand1.getId(), null); + PageCriteria pageCriteria = new PageCriteria(0, 10); + + // Act + IdListCacheEntry result = productQueryPort.searchProductIds(criteria, pageCriteria); + + // Assert + assertAll( + () -> assertThat(result.ids()).hasSize(1), + () -> assertThat(result.ids().get(0)).isEqualTo(p1.getId()), + () -> assertThat(result.totalElements()).isEqualTo(1) + ); + } + + + @Test + @DisplayName("[searchProductIds()] ์‚ญ์ œ๋œ ์ƒํ’ˆ ์ œ์™ธ -> ํ™œ์„ฑ ์ƒํ’ˆ ID๋งŒ ๋ฐ˜ํ™˜") + void searchProductIdsExcludesDeleted() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity active = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œ"); + + ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); + PageCriteria pageCriteria = new PageCriteria(0, 10); + + // Act + IdListCacheEntry result = productQueryPort.searchProductIds(criteria, pageCriteria); + + // Assert + assertAll( + () -> assertThat(result.ids()).hasSize(1), + () -> assertThat(result.ids().get(0)).isEqualTo(active.getId()), + () -> assertThat(result.totalElements()).isEqualTo(1) + ); + } + + + @Test + @DisplayName("[searchProductIds()] ํŽ˜์ด์ง€๋„ค์ด์…˜ -> ์ง€์ •๋œ ํŽ˜์ด์ง€ ํฌ๊ธฐ๋งŒํผ ID ๋ฐ˜ํ™˜. totalElements๋Š” ์ „์ฒด ๊ฐœ์ˆ˜") + void searchProductIdsWithPagination() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + for (int i = 1; i <= 5; i++) { + saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์ƒํ’ˆ" + i, new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + } + + ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); + PageCriteria pageCriteria = new PageCriteria(0, 2); + + // Act + IdListCacheEntry result = productQueryPort.searchProductIds(criteria, pageCriteria); + + // Assert + assertAll( + () -> assertThat(result.ids()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(5) + ); + } + + + @Test + @DisplayName("[searchProductIds()] ๋นˆ ๊ฒฐ๊ณผ -> ids ๋นˆ ๋ชฉ๋ก, totalElements 0") + void searchProductIdsEmpty() { + // Arrange + ProductSearchCriteria criteria = new ProductSearchCriteria(null, null); + PageCriteria pageCriteria = new PageCriteria(0, 10); + + // Act + IdListCacheEntry result = productQueryPort.searchProductIds(criteria, pageCriteria); + + // Assert + assertAll( + () -> assertThat(result.ids()).isEmpty(), + () -> assertThat(result.totalElements()).isEqualTo(0) + ); + } + + } + + + @Nested + @DisplayName("findProductCacheDtoById()") + class FindProductCacheDtoByIdTest { + + @Test + @DisplayName("[findProductCacheDtoById()] ํ™œ์„ฑ ์ƒํ’ˆ ID -> ProductCacheDto ๋ฐ˜ํ™˜. brandName, description ํฌํ•จ") + void findProductCacheDtoByIdSuccess() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", VisibleStatus.VISIBLE)); + ProductEntity product = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์—์–ด๋งฅ์Šค", new BigDecimal("129000.00"), 50L, "๋Ÿฌ๋‹ํ™”"), + "๋‚˜์ดํ‚ค", 10L); + + // Act + ProductCacheDto result = productQueryPort.findProductCacheDtoById(product.getId()); + + // Assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isEqualTo(product.getId()), + () -> assertThat(result.brandId()).isEqualTo(brand.getId()), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(result.price()).isEqualByComparingTo(new BigDecimal("129000.00")), + () -> assertThat(result.stock()).isEqualTo(50L), + () -> assertThat(result.description()).isEqualTo("๋Ÿฌ๋‹ํ™”"), + () -> assertThat(result.likeCount()).isEqualTo(10L) + ); + } + + + @Test + @DisplayName("[findProductCacheDtoById()] ์‚ญ์ œ๋œ ์ƒํ’ˆ ID -> null ๋ฐ˜ํ™˜") + void findProductCacheDtoByIdDeleted() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity deleted = saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + + // Act + ProductCacheDto result = productQueryPort.findProductCacheDtoById(deleted.getId()); + + // Assert + assertThat(result).isNull(); + } + + + @Test + @DisplayName("[findProductCacheDtoById()] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID -> null ๋ฐ˜ํ™˜") + void findProductCacheDtoByIdNotFound() { + // Act + ProductCacheDto result = productQueryPort.findProductCacheDtoById(999L); + + // Assert + assertThat(result).isNull(); + } + + } + + + @Nested + @DisplayName("findProductCacheDtosByIds()") + class FindProductCacheDtosByIdsTest { + + @Test + @DisplayName("[findProductCacheDtosByIds()] ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก -> ProductCacheDto ๋ชฉ๋ก ๋ฐ˜ํ™˜") + void findProductCacheDtosByIdsSuccess() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", VisibleStatus.VISIBLE)); + ProductEntity p1 = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์—์–ด๋งฅ์Šค", new BigDecimal("129000.00"), 50L, "๋Ÿฌ๋‹ํ™”"), + "๋‚˜์ดํ‚ค", 10L); + ProductEntity p2 = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์—์–ดํฌ์Šค", new BigDecimal("119000.00"), 30L, "์บ์ฃผ์–ผ"), + "๋‚˜์ดํ‚ค", 20L); + + // Act + List result = productQueryPort.findProductCacheDtosByIds( + List.of(p1.getId(), p2.getId())); + + // Assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result).extracting(ProductCacheDto::name) + .containsExactlyInAnyOrder("์—์–ด๋งฅ์Šค", "์—์–ดํฌ์Šค") + ); + } + + + @Test + @DisplayName("[findProductCacheDtosByIds()] ์‚ญ์ œ๋œ ์ƒํ’ˆ ํฌํ•จ ID ๋ชฉ๋ก -> ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ ๋ฐ˜ํ™˜") + void findProductCacheDtosByIdsExcludesDeleted() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); + ProductEntity active = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "ํ™œ์„ฑ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, null), + "๋ธŒ๋žœ๋“œ"); + ProductEntity deleted = saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("20000.00"), 200L, null), + "๋ธŒ๋žœ๋“œ"); + + // Act + List result = productQueryPort.findProductCacheDtosByIds( + List.of(active.getId(), deleted.getId())); + + // Assert โ€” ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ ๋ฐ˜ํ™˜ + assertAll( + () -> assertThat(result).hasSize(1), + () -> assertThat(result.get(0).id()).isEqualTo(active.getId()), + () -> assertThat(result.get(0).name()).isEqualTo("ํ™œ์„ฑ ์ƒํ’ˆ") + ); + } + + + @Test + @DisplayName("[findProductCacheDtosByIds()] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋งŒ ํฌํ•จ -> ๋นˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜") + void findProductCacheDtosByIdsAllNotFound() { + // Act + List result = productQueryPort.findProductCacheDtosByIds(List.of(999L, 1000L)); + + // Assert + assertThat(result).isEmpty(); + } + + } + + + @Nested + @DisplayName("findAdminProductDetailById()") + class FindAdminProductDetailByIdTest { + + @Test + @DisplayName("[findAdminProductDetailById()] ํ™œ์„ฑ ์ƒํ’ˆ ID -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. ๋ชจ๋“  ํ•„๋“œ ํฌํ•จ") + void findAdminProductDetailByIdSuccess() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ", VisibleStatus.VISIBLE)); + ProductEntity product = saveProductWithReadModel( + ProductEntity.of(brand.getId(), "์—์–ด๋งฅ์Šค", new BigDecimal("129000.00"), 50L, "๋Ÿฌ๋‹ํ™”"), + "๋‚˜์ดํ‚ค", 10L); + + // Act + AdminProductDetailOutDto result = productQueryPort.findAdminProductDetailById(product.getId()); + + // Assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isEqualTo(product.getId()), + () -> assertThat(result.brandId()).isEqualTo(brand.getId()), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.name()).isEqualTo("์—์–ด๋งฅ์Šค"), + () -> assertThat(result.price()).isEqualByComparingTo(new BigDecimal("129000.00")), + () -> assertThat(result.stock()).isEqualTo(50L), + () -> assertThat(result.description()).isEqualTo("๋Ÿฌ๋‹ํ™”"), + () -> assertThat(result.likeCount()).isEqualTo(10L), + () -> assertThat(result.deletedAt()).isNull() + ); + } + + + @Test + @DisplayName("[findAdminProductDetailById()] ์‚ญ์ œ๋œ ์ƒํ’ˆ ID -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. deletedAt ํฌํ•จ") + void findAdminProductDetailByIdDeleted() { + // Arrange + BrandEntity brand = brandJpaRepository.save( + BrandEntity.of("์•„๋””๋‹ค์Šค", "์Šคํฌ์ธ ", VisibleStatus.VISIBLE)); + ProductEntity deleted = saveDeletedProductWithReadModel( + ProductEntity.of(brand.getId(), "์‚ญ์ œ ์ƒํ’ˆ", new BigDecimal("50000.00"), 10L, null), + "์•„๋””๋‹ค์Šค"); + + // Act + AdminProductDetailOutDto result = productQueryPort.findAdminProductDetailById(deleted.getId()); + + // Assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.id()).isEqualTo(deleted.getId()), + () -> assertThat(result.brandName()).isEqualTo("์•„๋””๋‹ค์Šค"), + () -> assertThat(result.name()).isEqualTo("์‚ญ์ œ ์ƒํ’ˆ"), + () -> assertThat(result.deletedAt()).isNotNull() + ); + } + + + @Test + @DisplayName("[findAdminProductDetailById()] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID -> null ๋ฐ˜ํ™˜") + void findAdminProductDetailByIdNotFound() { + // Act + AdminProductDetailOutDto result = productQueryPort.findAdminProductDetailById(999L); + + // Assert + assertThat(result).isNull(); + } + + } + } From 403a561b439d4d58d8441967b23090519739212d Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:22:02 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20ReadModel=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCommandService์— ReadModel ๋™๊ธฐํ™” ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ (์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ/์ข‹์•„์š” ์ฆ๊ฐ) - BrandCommandFacade ๋ธŒ๋žœ๋“œ ์ˆ˜์ •/์‚ญ์ œ ์‹œ ReadModel ๋™๊ธฐํ™” ํ˜ธ์ถœ - ProductCommandServiceTest ReadModel ๋™๊ธฐํ™” ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - BrandCommandFacadeTest ReadModel ๋™๊ธฐํ™” ๊ฒ€์ฆ ์ถ”๊ฐ€ - ProductLikeCountConcurrencyTest ReadModel ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ - ProductStockConcurrencyTest, ProductLikeCountSyncerImplTest, OrderProductReaderImplTest ์—…๋ฐ์ดํŠธ Co-Authored-By: Claude Opus 4.6 --- .../facade/BrandCommandFacade.java | 17 +- .../service/ProductCommandService.java | 127 +++++++++++++- .../facade/BrandCommandFacadeTest.java | 15 +- .../service/ProductCommandServiceTest.java | 166 +++++++++++++++--- .../ProductLikeCountConcurrencyTest.java | 53 ++++-- .../service/ProductStockConcurrencyTest.java | 4 +- .../ProductLikeCountSyncerImplTest.java | 26 +-- .../catalog/OrderProductReaderImplTest.java | 12 +- 8 files changed, 359 insertions(+), 61 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/brand/application/facade/BrandCommandFacade.java b/apps/commerce-api/src/main/java/com/loopers/catalog/brand/application/facade/BrandCommandFacade.java index cae9d7d8d..705d49c5c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/brand/application/facade/BrandCommandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/brand/application/facade/BrandCommandFacade.java @@ -8,11 +8,14 @@ import com.loopers.catalog.brand.application.service.BrandCommandService; import com.loopers.catalog.brand.application.service.BrandQueryService; import com.loopers.catalog.brand.domain.model.Brand; +import com.loopers.catalog.product.application.service.ProductCommandService; import com.loopers.catalog.product.application.service.ProductQueryService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @@ -22,12 +25,13 @@ public class BrandCommandFacade { private final BrandCommandService brandCommandService; private final BrandQueryService brandQueryService; private final ProductQueryService productQueryService; + private final ProductCommandService productCommandService; /** * ๋ธŒ๋žœ๋“œ ๋ช…๋ น ํŒŒ์‚ฌ๋“œ * 1. ๋ธŒ๋žœ๋“œ ์ƒ์„ฑ - * 2. ๋ธŒ๋žœ๋“œ ์ˆ˜์ • + * 2. ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through) * 3. ๋ธŒ๋žœ๋“œ ์‚ญ์ œ * 4. ๋ธŒ๋žœ๋“œ ๋…ธ์ถœ ์ƒํƒœ ๋ณ€๊ฒฝ */ @@ -44,7 +48,7 @@ public AdminBrandDetailOutDto createBrand(AdminBrandCreateInDto inDto) { } - // 2. ๋ธŒ๋žœ๋“œ ์ˆ˜์ • + // 2. ๋ธŒ๋žœ๋“œ ์ˆ˜์ • (๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through) @Transactional public AdminBrandDetailOutDto updateBrand(Long id, AdminBrandUpdateInDto inDto) { @@ -54,6 +58,15 @@ public AdminBrandDetailOutDto updateBrand(Long id, AdminBrandUpdateInDto inDto) // ๋ธŒ๋žœ๋“œ ์ˆ˜์ • Brand updatedBrand = brandCommandService.updateBrand(brand, inDto); + // ์ƒํ’ˆ Read Model์˜ brand_name ์ผ๊ด„ ๋™๊ธฐํ™” + productCommandService.syncBrandNameInReadModel(id, updatedBrand.getName().value()); + + // ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through (ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ์ „์ฒด ์ƒํ’ˆ) + List productIds = productQueryService.findActiveIdsByBrandId(id); + for (Long productId : productIds) { + productCommandService.refreshProductDetailCache(productId); + } + // DTO ๋ณ€ํ™˜ return AdminBrandDetailOutDto.from(updatedBrand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java index abcdf8732..5f95f456e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java @@ -5,15 +5,24 @@ import com.loopers.catalog.product.application.dto.in.AdminProductUpdateInDto; import com.loopers.catalog.product.application.port.out.client.cart.CartItemCleanupManager; import com.loopers.catalog.product.application.port.out.client.engagement.ProductLikeCleanupManager; +import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; +import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.repository.ProductCommandRepository; import com.loopers.catalog.product.domain.repository.ProductQueryRepository; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; +import com.loopers.catalog.product.domain.repository.vo.PageCriteria; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.DEFAULT_PAGE_SIZE; +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.MAX_CACHEABLE_PAGE; + @Service public class ProductCommandService { @@ -21,6 +30,11 @@ public class ProductCommandService { // repository private final ProductCommandRepository productCommandRepository; private final ProductQueryRepository productQueryRepository; + private final ProductReadModelRepository readModelRepository; + // cache + private final ProductCacheManager productCacheManager; + // port + private final ProductQueryPort productQueryPort; // port (@Lazy: Cross-BC ์ˆœํ™˜ ์˜์กด ๋ฐฉ์ง€ โ€” ProductCommandService โ†” ProductLikeCommandService ๊ฐ„ ACL ๊ฒฝ์œ  ์ˆœํ™˜) private final ProductLikeCleanupManager productLikeCleanupManager; private final CartItemCleanupManager cartItemCleanupManager; @@ -28,11 +42,17 @@ public class ProductCommandService { public ProductCommandService( ProductCommandRepository productCommandRepository, ProductQueryRepository productQueryRepository, + ProductReadModelRepository readModelRepository, + ProductCacheManager productCacheManager, + ProductQueryPort productQueryPort, @Lazy ProductLikeCleanupManager productLikeCleanupManager, @Lazy CartItemCleanupManager cartItemCleanupManager ) { this.productCommandRepository = productCommandRepository; this.productQueryRepository = productQueryRepository; + this.readModelRepository = readModelRepository; + this.productCacheManager = productCacheManager; + this.productQueryPort = productQueryPort; this.productLikeCleanupManager = productLikeCleanupManager; this.cartItemCleanupManager = cartItemCleanupManager; } @@ -43,11 +63,17 @@ public ProductCommandService( * 1. ์ƒํ’ˆ ์ƒ์„ฑ * 2. ์ƒํ’ˆ ์ˆ˜์ • * 3. ์ƒํ’ˆ ์‚ญ์ œ - * 4. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ (์›์ž์  ์นด์šดํ„ฐ) - * 5. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ (์›์ž์  ์นด์šดํ„ฐ) - * 6. ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ) + * 4. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ (Read Model ์›์ž์  ์นด์šดํ„ฐ + ์ƒ์„ธ ์บ์‹œ write-through) + * 5. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ (Read Model ์›์ž์  ์นด์šดํ„ฐ + ์ƒ์„ธ ์บ์‹œ write-through) + * 6. ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ + ์ƒ์„ธ ์บ์‹œ write-through) * 7. ์ƒํ’ˆ ์ข‹์•„์š” ์ „์ฒด ์‚ญ์ œ * 8. ์žฅ๋ฐ”๊ตฌ๋‹ˆ ํ•ญ๋ชฉ ์ „์ฒด ์‚ญ์ œ + * 9. Read Model ๋™๊ธฐํ™” (์ƒํ’ˆ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ Facade์—์„œ ํ˜ธ์ถœ) + * 10. Read Model ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ๋™๊ธฐํ™” (๋ธŒ๋žœ๋“œ ์ˆ˜์ • ์‹œ ํ˜ธ์ถœ) + * 11. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through (Facade์—์„œ ํ˜ธ์ถœ) + * 12. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ (์ƒํ’ˆ ์‚ญ์ œ ์‹œ Facade์—์„œ ํ˜ธ์ถœ) + * 13. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through โ€” ๋ชจ๋“  ์ •๋ ฌ (Facade์—์„œ ํ˜ธ์ถœ) + * 14. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through โ€” ํŠน์ • ์ •๋ ฌ (Facade์—์„œ ํ˜ธ์ถœ) */ // 1. ์ƒํ’ˆ ์ƒ์„ฑ @@ -92,24 +118,37 @@ public void deleteProduct(Product product) { // ์‚ญ์ œ ์ €์žฅ productCommandRepository.delete(product); + + // Read Model soft delete (๊ด€๋ฆฌ์ž ๋ชฉ๋ก ์กฐํšŒ์—์„œ ์‚ญ์ œ ์ƒํ’ˆ๋„ ์กฐํšŒ ๊ฐ€๋Šฅํ•˜๋„๋ก) + readModelRepository.softDelete(product.getId()); } - // 4. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ (์›์ž์  ์นด์šดํ„ฐ โ€” ๋‹จ์ผ UPDATE SQL๋กœ ๋™์‹œ์„ฑ ์•ˆ์ „) + // 4. ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ (Read Model ์›์ž์  ์นด์šดํ„ฐ + ์ƒ์„ธ ์บ์‹œ write-through) @Transactional public void increaseLikeCount(Long productId) { - productCommandRepository.increaseLikeCount(productId); + + // Read Model ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ (likes ํ…Œ์ด๋ธ”์ด SoT, Read Model์ด ์œ ์ผํ•œ projection) + readModelRepository.increaseLikeCount(productId); + + // ์ƒ์„ธ ์บ์‹œ write-through (ID ๋ฆฌ์ŠคํŠธ๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ โ€” ๊ณ ๋นˆ๋„ ํŠธ๋ฆฌ๊ฑฐ ์ตœ์ ํ™”) + productCacheManager.refreshProductDetail(productId, () -> productQueryPort.findProductCacheDtoById(productId)); } - // 5. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ (์›์ž์  ์นด์šดํ„ฐ โ€” ๋‹จ์ผ UPDATE SQL๋กœ ๋™์‹œ์„ฑ ์•ˆ์ „) + // 5. ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ (Read Model ์›์ž์  ์นด์šดํ„ฐ + ์ƒ์„ธ ์บ์‹œ write-through) @Transactional public void decreaseLikeCount(Long productId) { - productCommandRepository.decreaseLikeCount(productId); + + // Read Model ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ (likes ํ…Œ์ด๋ธ”์ด SoT, Read Model์ด ์œ ์ผํ•œ projection) + readModelRepository.decreaseLikeCount(productId); + + // ์ƒ์„ธ ์บ์‹œ write-through (ID ๋ฆฌ์ŠคํŠธ๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ โ€” ๊ณ ๋นˆ๋„ ํŠธ๋ฆฌ๊ฑฐ ์ตœ์ ํ™”) + productCacheManager.refreshProductDetail(productId, () -> productQueryPort.findProductCacheDtoById(productId)); } - // 6. ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ) + // 6. ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ (๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ + ์ƒ์„ธ ์บ์‹œ write-through) @Transactional public void decreaseStock(Long productId, Long quantity) { @@ -122,6 +161,12 @@ public void decreaseStock(Long productId, Long quantity) { // ์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ ์ƒํ’ˆ ์ €์žฅ productCommandRepository.save(product); + + // Read Model ์žฌ๊ณ  ๋™๊ธฐํ™” + readModelRepository.updateStock(productId, product.getStock().value()); + + // ์ƒ์„ธ ์บ์‹œ write-through (์žฌ๊ณ ๋Š” ์ •๋ ฌ ๊ธฐ์ค€ ์•„๋‹˜ โ€” ID ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹  ๋ถˆํ•„์š”) + productCacheManager.refreshProductDetail(productId, () -> productQueryPort.findProductCacheDtoById(productId)); } @@ -138,4 +183,70 @@ public void deleteAllCartItems(Long productId) { cartItemCleanupManager.deleteAllByProductId(productId); } + + // 9. Read Model ๋™๊ธฐํ™” (์ƒํ’ˆ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ Facade์—์„œ ํ˜ธ์ถœ) + @Transactional + public void syncReadModel(Product product, String brandName) { + readModelRepository.save(product, brandName); + } + + + // 10. Read Model ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ๋™๊ธฐํ™” (๋ธŒ๋žœ๋“œ ์ˆ˜์ • ์‹œ BrandCommandFacade์—์„œ ํ˜ธ์ถœ) + @Transactional + public void syncBrandNameInReadModel(Long brandId, String brandName) { + readModelRepository.updateBrandName(brandId, brandName); + } + + + // 11. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through (Facade์—์„œ ํ˜ธ์ถœ โ€” Read Model projection ๊ธฐ๋ฐ˜) + public void refreshProductDetailCache(Long productId) { + productCacheManager.refreshProductDetail(productId, () -> productQueryPort.findProductCacheDtoById(productId)); + } + + + // 12. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ (์ƒํ’ˆ ์‚ญ์ œ ์‹œ Facade์—์„œ ํ˜ธ์ถœ) + public void deleteProductDetailCache(Long productId) { + productCacheManager.deleteProductDetail(productId); + } + + + // 13. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through โ€” ๋ชจ๋“  ์ •๋ ฌ (Facade์—์„œ ํ˜ธ์ถœ) + public void refreshIdListCacheForAllSorts(Long brandId) { + for (ProductSortType sort : ProductSortType.values()) { + refreshIdListCacheForSort(brandId, sort); + } + } + + + // 14. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through โ€” ํŠน์ • ์ •๋ ฌ (Facade์—์„œ ํ˜ธ์ถœ) + public void refreshIdListCacheForSort(Long brandId, ProductSortType sortType) { + for (int page = 0; page < MAX_CACHEABLE_PAGE; page++) { + // brandId ์กฐ๊ฑด ๊ฐฑ์‹  + refreshSingleIdList(brandId, sortType, page); + // all ์กฐ๊ฑด ๊ฐฑ์‹  + refreshSingleIdList(null, sortType, page); + } + } + + + /** + * private method โ€” ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through + */ + + // ๋‹จ๊ฑด ID ๋ฆฌ์ŠคํŠธ write-through + private void refreshSingleIdList(Long brandId, ProductSortType sortType, int page) { + String cacheKey = buildIdListCacheKey(brandId, sortType, page, DEFAULT_PAGE_SIZE); + ProductSearchCriteria criteria = new ProductSearchCriteria(brandId, sortType); + PageCriteria pageCriteria = new PageCriteria(page, DEFAULT_PAGE_SIZE); + productCacheManager.refreshIdList(cacheKey, () -> productQueryPort.searchProductIds(criteria, pageCriteria)); + } + + + // ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํ‚ค ์ƒ์„ฑ: products:ids:v1:{brandId|all}:{sortType|LATEST}:{page}:{size} + private String buildIdListCacheKey(Long brandId, ProductSortType sortType, int page, int size) { + String brandPart = brandId != null ? brandId.toString() : "all"; + String sortPart = sortType != null ? sortType.name() : "LATEST"; + return "products:ids:v1:" + brandPart + ":" + sortPart + ":" + page + ":" + size; + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/brand/application/facade/BrandCommandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/brand/application/facade/BrandCommandFacadeTest.java index ceb1a89ff..785b41d04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/brand/application/facade/BrandCommandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/brand/application/facade/BrandCommandFacadeTest.java @@ -11,6 +11,7 @@ import com.loopers.catalog.brand.domain.model.enums.VisibleStatus; import com.loopers.catalog.brand.domain.model.vo.BrandDescription; import com.loopers.catalog.brand.domain.model.vo.BrandName; +import com.loopers.catalog.product.application.service.ProductCommandService; import com.loopers.catalog.product.application.service.ProductQueryService; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; @@ -22,6 +23,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -44,13 +47,16 @@ class BrandCommandFacadeTest { @Mock private ProductQueryService productQueryService; + @Mock + private ProductCommandService productCommandService; + private BrandCommandFacade brandCommandFacade; @BeforeEach void setUp() { brandCommandFacade = new BrandCommandFacade( - brandCommandService, brandQueryService, productQueryService + brandCommandService, brandQueryService, productQueryService, productCommandService ); } @@ -89,7 +95,7 @@ void createBrandSuccess() { class UpdateBrandTest { @Test - @DisplayName("[BrandCommandFacade.updateBrand()] ์œ ํšจํ•œ ์ž…๋ ฅ -> ์กฐํšŒ ํ›„ ์ˆ˜์ •. AdminBrandDetailOutDto ๋ฐ˜ํ™˜") + @DisplayName("[BrandCommandFacade.updateBrand()] ์œ ํšจํ•œ ์ž…๋ ฅ -> ์กฐํšŒ ํ›„ ์ˆ˜์ •. AdminBrandDetailOutDto ๋ฐ˜ํ™˜. Read Model ๋ธŒ๋žœ๋“œ๋ช… ๋™๊ธฐํ™” + ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through") void updateBrandSuccess() { // Arrange Brand brand = Brand.reconstruct(1L, BrandName.from("๋‚˜์ดํ‚ค"), @@ -100,6 +106,7 @@ void updateBrandSuccess() { given(brandQueryService.getBrandById(1L)).willReturn(brand); given(brandCommandService.updateBrand(brand, inDto)).willReturn(updatedBrand); + given(productQueryService.findActiveIdsByBrandId(1L)).willReturn(List.of(10L, 20L)); // Act AdminBrandDetailOutDto result = brandCommandFacade.updateBrand(1L, inDto); @@ -113,6 +120,10 @@ void updateBrandSuccess() { ); verify(brandQueryService).getBrandById(1L); verify(brandCommandService).updateBrand(brand, inDto); + verify(productCommandService).syncBrandNameInReadModel(1L, "์•„๋””๋‹ค์Šค"); + verify(productQueryService).findActiveIdsByBrandId(1L); + verify(productCommandService).refreshProductDetailCache(10L); + verify(productCommandService).refreshProductDetailCache(20L); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductCommandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductCommandServiceTest.java index 3105efaae..b75f20637 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductCommandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductCommandServiceTest.java @@ -5,12 +5,16 @@ import com.loopers.catalog.product.application.dto.in.AdminProductUpdateInDto; import com.loopers.catalog.product.application.port.out.client.cart.CartItemCleanupManager; import com.loopers.catalog.product.application.port.out.client.engagement.ProductLikeCleanupManager; +import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.model.vo.Money; import com.loopers.catalog.product.domain.model.vo.ProductName; import com.loopers.catalog.product.domain.model.vo.Stock; import com.loopers.catalog.product.domain.repository.ProductCommandRepository; import com.loopers.catalog.product.domain.repository.ProductQueryRepository; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -27,7 +31,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -40,6 +46,12 @@ class ProductCommandServiceTest { @Mock private ProductQueryRepository productQueryRepository; @Mock + private ProductReadModelRepository readModelRepository; + @Mock + private ProductCacheManager productCacheManager; + @Mock + private ProductQueryPort productQueryPort; + @Mock private ProductLikeCleanupManager productLikeCleanupManager; @Mock private CartItemCleanupManager cartItemCleanupManager; @@ -50,8 +62,8 @@ class ProductCommandServiceTest { @BeforeEach void setUp() { productCommandService = new ProductCommandService( - productCommandRepository, productQueryRepository, - productLikeCleanupManager, cartItemCleanupManager + productCommandRepository, productQueryRepository, readModelRepository, + productCacheManager, productQueryPort, productLikeCleanupManager, cartItemCleanupManager ); } @@ -70,7 +82,7 @@ void createProductSuccess() { given(productCommandRepository.save(any(Product.class))).willAnswer(invocation -> { Product p = invocation.getArgument(0); return Product.reconstruct(1L, p.getBrandId(), p.getName(), p.getPrice(), - p.getStock(), p.getDescription(), p.getLikeCount(), p.getDeletedAt()); + p.getStock(), p.getDescription(), p.getDeletedAt()); }); // Act @@ -100,7 +112,7 @@ void updateProductSuccess() { ProductName.from("์›๋ž˜ ์ƒํ’ˆ"), Money.from(new BigDecimal("10000")), Stock.from(100L), - null, 0L, null); + null, null); AdminProductUpdateInDto inDto = new AdminProductUpdateInDto( "์ˆ˜์ • ์ƒํ’ˆ", new BigDecimal("20000"), 200L, "์ˆ˜์ • ์„ค๋ช…" ); @@ -126,14 +138,14 @@ void updateProductSuccess() { class DeleteProductTest { @Test - @DisplayName("[deleteProduct()] ํ™œ์„ฑ ์ƒํ’ˆ -> soft delete ์ˆ˜ํ–‰") + @DisplayName("[deleteProduct()] ํ™œ์„ฑ ์ƒํ’ˆ -> soft delete ์ˆ˜ํ–‰. Read Model soft delete ๋™๊ธฐํ™”") void deleteProductSuccess() { // Arrange Product product = Product.reconstruct(1L, 1L, ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(100L), - null, 0L, null); + null, null); // Act productCommandService.deleteProduct(product); @@ -141,7 +153,8 @@ void deleteProductSuccess() { // Assert assertAll( () -> assertThat(product.isDeleted()).isTrue(), - () -> verify(productCommandRepository).delete(product) + () -> verify(productCommandRepository).delete(product), + () -> verify(readModelRepository).softDelete(1L) ); } @@ -153,16 +166,16 @@ void deleteProductSuccess() { class IncreaseLikeCountTest { @Test - @DisplayName("[increaseLikeCount()] ์œ ํšจํ•œ ์ƒํ’ˆ ID -> ์›์ž์  ์นด์šดํ„ฐ๋กœ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ ์œ„์ž„") + @DisplayName("[increaseLikeCount()] ์œ ํšจํ•œ ์ƒํ’ˆ ID -> Read Model ์›์ž์  ์นด์šดํ„ฐ๋กœ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€. ์ƒ์„ธ ์บ์‹œ write-through") void increaseLikeCountSuccess() { - // Arrange - willDoNothing().given(productCommandRepository).increaseLikeCount(1L); - // Act productCommandService.increaseLikeCount(1L); // Assert - verify(productCommandRepository).increaseLikeCount(1L); + assertAll( + () -> verify(readModelRepository).increaseLikeCount(1L), + () -> verify(productCacheManager).refreshProductDetail(eq(1L), any()) + ); } } @@ -173,16 +186,16 @@ void increaseLikeCountSuccess() { class DecreaseLikeCountTest { @Test - @DisplayName("[decreaseLikeCount()] ์œ ํšจํ•œ ์ƒํ’ˆ ID -> ์›์ž์  ์นด์šดํ„ฐ๋กœ ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ ์œ„์ž„") + @DisplayName("[decreaseLikeCount()] ์œ ํšจํ•œ ์ƒํ’ˆ ID -> Read Model ์›์ž์  ์นด์šดํ„ฐ๋กœ ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ. ์ƒ์„ธ ์บ์‹œ write-through") void decreaseLikeCountSuccess() { - // Arrange - willDoNothing().given(productCommandRepository).decreaseLikeCount(1L); - // Act productCommandService.decreaseLikeCount(1L); // Assert - verify(productCommandRepository).decreaseLikeCount(1L); + assertAll( + () -> verify(readModelRepository).decreaseLikeCount(1L), + () -> verify(productCacheManager).refreshProductDetail(eq(1L), any()) + ); } } @@ -193,14 +206,14 @@ void decreaseLikeCountSuccess() { class DecreaseStockTest { @Test - @DisplayName("[decreaseStock()] ํ™œ์„ฑ ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ -> ์ฐจ๊ฐ ํ›„ ์ €์žฅ. ๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ์œผ๋กœ ์กฐํšŒ") + @DisplayName("[decreaseStock()] ํ™œ์„ฑ ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ -> ์ฐจ๊ฐ ํ›„ ์ €์žฅ. ๋น„๊ด€์  ์“ฐ๊ธฐ ๋ฝ์œผ๋กœ ์กฐํšŒ. Read Model ์žฌ๊ณ  ๋™๊ธฐํ™”. ์ƒ์„ธ ์บ์‹œ write-through") void decreaseStockSuccess() { // Arrange Product product = Product.reconstruct(1L, 1L, ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(100L), - null, 0L, null); + null, null); given(productQueryRepository.findActiveByIdForUpdate(1L)).willReturn(Optional.of(product)); given(productCommandRepository.save(any(Product.class))).willAnswer(invocation -> invocation.getArgument(0)); @@ -211,7 +224,9 @@ void decreaseStockSuccess() { assertAll( () -> assertThat(product.getStock().value()).isEqualTo(90L), () -> verify(productQueryRepository).findActiveByIdForUpdate(1L), - () -> verify(productCommandRepository).save(product) + () -> verify(productCommandRepository).save(product), + () -> verify(readModelRepository).updateStock(1L, 90L), + () -> verify(productCacheManager).refreshProductDetail(eq(1L), any()) ); } @@ -240,7 +255,7 @@ void decreaseStockOutOfStock() { ProductName.from("์ƒํ’ˆ"), Money.from(BigDecimal.TEN), Stock.from(5L), - null, 0L, null); + null, null); given(productQueryRepository.findActiveByIdForUpdate(1L)).willReturn(Optional.of(product)); // Act @@ -295,4 +310,113 @@ void deleteAllCartItemsSuccess() { } + + @Nested + @DisplayName("syncReadModel()") + class SyncReadModelTest { + + @Test + @DisplayName("[syncReadModel()] Product์™€ brandName์œผ๋กœ Read Model ์ €์žฅ -> readModelRepository.save() ํ˜ธ์ถœ") + void syncReadModelSuccess() { + // Arrange + Product product = Product.reconstruct(1L, 1L, + ProductName.from("์ƒํ’ˆ"), + Money.from(BigDecimal.TEN), + Stock.from(100L), + null, null); + + // Act + productCommandService.syncReadModel(product, "๋‚˜์ดํ‚ค"); + + // Assert + verify(readModelRepository).save(product, "๋‚˜์ดํ‚ค"); + } + + } + + + @Nested + @DisplayName("syncBrandNameInReadModel()") + class SyncBrandNameInReadModelTest { + + @Test + @DisplayName("[syncBrandNameInReadModel()] brandId์™€ brandName์œผ๋กœ Read Model ๋ธŒ๋žœ๋“œ๋ช… ์ผ๊ด„ ์—…๋ฐ์ดํŠธ -> readModelRepository.updateBrandName() ํ˜ธ์ถœ") + void syncBrandNameInReadModelSuccess() { + // Act + productCommandService.syncBrandNameInReadModel(1L, "์•„๋””๋‹ค์Šค"); + + // Assert + verify(readModelRepository).updateBrandName(1L, "์•„๋””๋‹ค์Šค"); + } + + } + + + @Nested + @DisplayName("deleteProductDetailCache()") + class DeleteProductDetailCacheTest { + + @Test + @DisplayName("[deleteProductDetailCache()] ์ƒํ’ˆ ID๋กœ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ -> deleteProductDetail() ํ˜ธ์ถœ") + void deleteProductDetailCacheSuccess() { + // Act + productCommandService.deleteProductDetailCache(1L); + + // Assert + verify(productCacheManager).deleteProductDetail(1L); + } + + } + + + @Nested + @DisplayName("refreshProductDetailCache()") + class RefreshProductDetailCacheTest { + + @Test + @DisplayName("[refreshProductDetailCache()] ์ƒํ’ˆ ID -> productCacheManager.refreshProductDetail() ํ˜ธ์ถœ. Supplier๋กœ QueryPort ์ „๋‹ฌ") + void refreshProductDetailCacheSuccess() { + // Act + productCommandService.refreshProductDetailCache(1L); + + // Assert + verify(productCacheManager).refreshProductDetail(eq(1L), any()); + } + + } + + + @Nested + @DisplayName("refreshIdListCacheForAllSorts()") + class RefreshIdListCacheForAllSortsTest { + + @Test + @DisplayName("[refreshIdListCacheForAllSorts()] brandId -> ๋ชจ๋“  ์ •๋ ฌ ร— cacheable ํŽ˜์ด์ง€ ร— (brand + all) ID ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹ ") + void refreshIdListCacheForAllSortsSuccess() { + // Act + productCommandService.refreshIdListCacheForAllSorts(1L); + + // Assert โ€” 3 ์ •๋ ฌ ร— 2 ํŽ˜์ด์ง€ ร— 2 (brand + all) = 12 calls + verify(productCacheManager, times(12)).refreshIdList(any(), any()); + } + + } + + + @Nested + @DisplayName("refreshIdListCacheForSort()") + class RefreshIdListCacheForSortTest { + + @Test + @DisplayName("[refreshIdListCacheForSort()] brandId + PRICE_ASC -> ํ•ด๋‹น ์ •๋ ฌ์˜ cacheable ํŽ˜์ด์ง€ ร— (brand + all) ID ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹ ") + void refreshIdListCacheForSortSuccess() { + // Act + productCommandService.refreshIdListCacheForSort(1L, ProductSortType.PRICE_ASC); + + // Assert โ€” 2 ํŽ˜์ด์ง€ ร— 2 (brand + all) = 4 calls + verify(productCacheManager, times(4)).refreshIdList(any(), any()); + } + + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductLikeCountConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductLikeCountConcurrencyTest.java index 2949ed6f1..b234f3932 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductLikeCountConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductLikeCountConcurrencyTest.java @@ -4,8 +4,14 @@ import com.loopers.catalog.brand.domain.model.enums.VisibleStatus; import com.loopers.catalog.brand.infrastructure.entity.BrandEntity; import com.loopers.catalog.brand.infrastructure.jpa.BrandJpaRepository; +import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.vo.Money; +import com.loopers.catalog.product.domain.model.vo.ProductName; +import com.loopers.catalog.product.domain.model.vo.Stock; import com.loopers.catalog.product.infrastructure.entity.ProductEntity; +import com.loopers.catalog.product.infrastructure.entity.ProductReadModelEntity; import com.loopers.catalog.product.infrastructure.jpa.ProductJpaRepository; +import com.loopers.catalog.product.infrastructure.jpa.ProductReadModelJpaRepository; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; @@ -18,6 +24,7 @@ import org.springframework.test.context.ActiveProfiles; import java.math.BigDecimal; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; @@ -40,6 +47,9 @@ class ProductLikeCountConcurrencyTest { @Autowired private ProductJpaRepository productJpaRepository; + @Autowired + private ProductReadModelJpaRepository productReadModelJpaRepository; + @Autowired private BrandJpaRepository brandJpaRepository; @@ -54,14 +64,15 @@ void tearDown() { @Test - @DisplayName("[increaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ 10๊ฑด -> likeCount ์ •ํ™•ํžˆ 10. ์›์ž์  ์นด์šดํ„ฐ๋กœ Lost Update ๋ฐฉ์ง€") + @DisplayName("[increaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ 10๊ฑด -> Read Model likeCount ์ •ํ™•ํžˆ 10. ์›์ž์  ์นด์šดํ„ฐ๋กœ Lost Update ๋ฐฉ์ง€") void concurrentIncreaseLikeCount() throws Exception { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); ProductEntity product = productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…", 0L)); + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…")); Long productId = product.getId(); + saveReadModel(product, "ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", 0L); int threadCount = 10; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); @@ -77,21 +88,22 @@ void concurrentIncreaseLikeCount() throws Exception { } executorService.shutdown(); - // Assert - ProductEntity result = productJpaRepository.findById(productId).orElseThrow(); + // Assert โ€” Read Model์˜ likeCount ๊ฒ€์ฆ + ProductReadModelEntity result = productReadModelJpaRepository.findById(productId).orElseThrow(); assertThat(result.getLikeCount()).isEqualTo(10L); } @Test - @DisplayName("[decreaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ 10๊ฑด -> likeCount ์ •ํ™•ํžˆ 0. ์›์ž์  ์นด์šดํ„ฐ๋กœ ์Œ์ˆ˜ ๋ฐฉ์ง€") + @DisplayName("[decreaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ 10๊ฑด -> Read Model likeCount ์ •ํ™•ํžˆ 0. ์›์ž์  ์นด์šดํ„ฐ๋กœ ์Œ์ˆ˜ ๋ฐฉ์ง€") void concurrentDecreaseLikeCount() throws Exception { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); ProductEntity product = productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…", 10L)); + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…")); Long productId = product.getId(); + saveReadModel(product, "ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", 10L); int threadCount = 10; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); @@ -107,21 +119,22 @@ void concurrentDecreaseLikeCount() throws Exception { } executorService.shutdown(); - // Assert - ProductEntity result = productJpaRepository.findById(productId).orElseThrow(); + // Assert โ€” Read Model์˜ likeCount ๊ฒ€์ฆ + ProductReadModelEntity result = productReadModelJpaRepository.findById(productId).orElseThrow(); assertThat(result.getLikeCount()).isEqualTo(0L); } @Test - @DisplayName("[increaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ 50๊ฑด -> likeCount ์ •ํ™•ํžˆ 50. ๋†’์€ ๊ฒฝํ•ฉ์—์„œ๋„ ์›์ž์  ์นด์šดํ„ฐ ์ •ํ™•์„ฑ ๋ณด์žฅ") + @DisplayName("[increaseLikeCount()] ๋™์‹œ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ 50๊ฑด -> Read Model likeCount ์ •ํ™•ํžˆ 50. ๋†’์€ ๊ฒฝํ•ฉ์—์„œ๋„ ์›์ž์  ์นด์šดํ„ฐ ์ •ํ™•์„ฑ ๋ณด์žฅ") void concurrentIncreaseLikeCountHighContention() throws Exception { // Arrange BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); ProductEntity product = productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…", 0L)); + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…")); Long productId = product.getId(); + saveReadModel(product, "ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", 0L); int threadCount = 50; ExecutorService executorService = Executors.newFixedThreadPool(threadCount); @@ -137,9 +150,25 @@ void concurrentIncreaseLikeCountHighContention() throws Exception { } executorService.shutdown(); - // Assert - ProductEntity result = productJpaRepository.findById(productId).orElseThrow(); + // Assert โ€” Read Model์˜ likeCount ๊ฒ€์ฆ + ProductReadModelEntity result = productReadModelJpaRepository.findById(productId).orElseThrow(); assertThat(result.getLikeCount()).isEqualTo(50L); } + + // Read Model ์ €์žฅ ํ—ฌํผ + private void saveReadModel(ProductEntity productEntity, String brandName, Long likeCount) { + Product product = Product.reconstruct( + productEntity.getId(), + productEntity.getBrandId(), + ProductName.from(productEntity.getName()), + Money.from(productEntity.getPrice()), + Stock.from(productEntity.getStock()), + null, + productEntity.getDeletedAt() + ); + productReadModelJpaRepository.save( + ProductReadModelEntity.of(product, brandName, ZonedDateTime.now(), likeCount)); + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductStockConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductStockConcurrencyTest.java index 9ae16639d..e760a0b69 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductStockConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductStockConcurrencyTest.java @@ -64,7 +64,7 @@ void concurrentDecreaseStock() throws Exception { BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); ProductEntity product = productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…", 0L)); + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 100L, "์„ค๋ช…")); Long productId = product.getId(); int threadCount = 10; @@ -94,7 +94,7 @@ void concurrentDecreaseStockInsufficientStock() throws Exception { BrandEntity brand = brandJpaRepository.save( BrandEntity.of("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "์„ค๋ช…", VisibleStatus.VISIBLE)); ProductEntity product = productJpaRepository.save( - ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 5L, "์„ค๋ช…", 0L)); + ProductEntity.of(brand.getId(), "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000.00"), 5L, "์„ค๋ช…")); Long productId = product.getId(); int threadCount = 10; diff --git a/apps/commerce-api/src/test/java/com/loopers/engagement/productlike/infrastructure/acl/catalog/ProductLikeCountSyncerImplTest.java b/apps/commerce-api/src/test/java/com/loopers/engagement/productlike/infrastructure/acl/catalog/ProductLikeCountSyncerImplTest.java index 1ae02bade..86e19130d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/engagement/productlike/infrastructure/acl/catalog/ProductLikeCountSyncerImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/engagement/productlike/infrastructure/acl/catalog/ProductLikeCountSyncerImplTest.java @@ -7,9 +7,11 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.verify; @@ -35,16 +37,18 @@ void setUp() { class IncreaseLikeCountTest { @Test - @DisplayName("[increaseLikeCount()] ์ƒํ’ˆ ID ์ „๋‹ฌ -> Provider Facade์— ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ ์œ„์ž„") + @DisplayName("[increaseLikeCount()] ์ƒํ’ˆ ID ์ „๋‹ฌ -> Provider Facade์— ๋™์ผํ•œ ์ƒํ’ˆ ID๋กœ ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ€ ์œ„์ž„") void increaseLikeCountSuccess() { // Arrange - willDoNothing().given(productCommandFacade).increaseLikeCount(1L); + Long productId = 42L; // Act - productLikeCountSyncerImpl.increaseLikeCount(1L); + productLikeCountSyncerImpl.increaseLikeCount(productId); - // Assert - verify(productCommandFacade).increaseLikeCount(1L); + // Assert โ€” ์ „๋‹ฌ๋œ ์ƒํ’ˆ ID๊ฐ€ ์ •ํ™•ํžˆ ์œ„์ž„๋จ์„ ๊ฒ€์ฆ + ArgumentCaptor captor = ArgumentCaptor.forClass(Long.class); + verify(productCommandFacade).increaseLikeCount(captor.capture()); + assertThat(captor.getValue()).isEqualTo(productId); } } @@ -55,16 +59,18 @@ void increaseLikeCountSuccess() { class DecreaseLikeCountTest { @Test - @DisplayName("[decreaseLikeCount()] ์ƒํ’ˆ ID ์ „๋‹ฌ -> Provider Facade์— ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ ์œ„์ž„") + @DisplayName("[decreaseLikeCount()] ์ƒํ’ˆ ID ์ „๋‹ฌ -> Provider Facade์— ๋™์ผํ•œ ์ƒํ’ˆ ID๋กœ ์ข‹์•„์š” ์ˆ˜ ๊ฐ์†Œ ์œ„์ž„") void decreaseLikeCountSuccess() { // Arrange - willDoNothing().given(productCommandFacade).decreaseLikeCount(1L); + Long productId = 42L; // Act - productLikeCountSyncerImpl.decreaseLikeCount(1L); + productLikeCountSyncerImpl.decreaseLikeCount(productId); - // Assert - verify(productCommandFacade).decreaseLikeCount(1L); + // Assert โ€” ์ „๋‹ฌ๋œ ์ƒํ’ˆ ID๊ฐ€ ์ •ํ™•ํžˆ ์œ„์ž„๋จ์„ ๊ฒ€์ฆ + ArgumentCaptor captor = ArgumentCaptor.forClass(Long.class); + verify(productCommandFacade).decreaseLikeCount(captor.capture()); + assertThat(captor.getValue()).isEqualTo(productId); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/ordering/order/infrastructure/acl/catalog/OrderProductReaderImplTest.java b/apps/commerce-api/src/test/java/com/loopers/ordering/order/infrastructure/acl/catalog/OrderProductReaderImplTest.java index bbcf2b030..951f74dda 100644 --- a/apps/commerce-api/src/test/java/com/loopers/ordering/order/infrastructure/acl/catalog/OrderProductReaderImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/ordering/order/infrastructure/acl/catalog/OrderProductReaderImplTest.java @@ -48,24 +48,28 @@ void readProductsSuccess() { ProductName.from("๋‚˜์ดํ‚ค ์—์–ด๋งฅ์Šค"), Money.from(new BigDecimal("100000")), Stock.from(10L), - null, 0L, null); + null, null); Product p2 = Product.reconstruct(2L, 1L, ProductName.from("์•„๋””๋‹ค์Šค ์šธํŠธ๋ผ๋ถ€์ŠคํŠธ"), Money.from(new BigDecimal("200000")), Stock.from(5L), - null, 0L, null); + null, null); given(productQueryFacade.findActiveByIds(productIds)).willReturn(List.of(p1, p2)); // Act List result = orderProductReaderImpl.readProducts(productIds); - // Assert + // Assert โ€” Product โ†’ OrderProductInfo ๋ณ€ํ™˜ ์ „์ฒด ํ•„๋“œ ๊ฒ€์ฆ assertAll( () -> assertThat(result).hasSize(2), () -> assertThat(result.get(0).productId()).isEqualTo(1L), () -> assertThat(result.get(0).name()).isEqualTo("๋‚˜์ดํ‚ค ์—์–ด๋งฅ์Šค"), + () -> assertThat(result.get(0).price()).isEqualByComparingTo(new BigDecimal("100000")), + () -> assertThat(result.get(0).stock()).isEqualTo(10L), () -> assertThat(result.get(1).productId()).isEqualTo(2L), - () -> verify(productQueryFacade).findActiveByIds(productIds) + () -> assertThat(result.get(1).name()).isEqualTo("์•„๋””๋‹ค์Šค ์šธํŠธ๋ผ๋ถ€์ŠคํŠธ"), + () -> assertThat(result.get(1).price()).isEqualByComparingTo(new BigDecimal("200000")), + () -> assertThat(result.get(1).stock()).isEqualTo(5L) ); } From a94fd35695051a8d895c0c5076d6137f5885e5dc Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:22:17 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20(ProductCacheManager)=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductCacheManager: Cache-Aside ํŒจํ„ด, try-catch ์žฅ์•  ๊ฒฉ๋ฆฌ, TTL jitter - ProductCacheConstants: ์บ์‹œ ํ‚ค/TTL ์ƒ์ˆ˜ ์ •์˜ - ProductCacheDto: ์บ์‹œ ์ „์šฉ DTO (Redis ์ง๋ ฌํ™”) - IdListCacheEntry: ID ๋ชฉ๋ก ์บ์‹œ ์—”ํŠธ๋ฆฌ - CacheLock, LocalCacheLock, RedisCacheLock: ์บ์‹œ stampede ๋ฐฉ์–ด - ProductCacheManagerTest: ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”, TTL, ์žฅ์•  ๊ฒฉ๋ฆฌ ํ…Œ์ŠคํŠธ - CacheStampedeTest: stampede ๋ฐฉ์–ด ๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ - LocalCacheLockTest: ๋กœ์ปฌ ๋ฝ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ Co-Authored-By: Claude Opus 4.6 --- .../service/ProductCommandService.java | 9 +- .../infrastructure/cache/CacheLock.java | 16 + .../cache/IdListCacheEntry.java | 14 + .../infrastructure/cache/LocalCacheLock.java | 46 ++ .../cache/ProductCacheConstants.java | 50 ++ .../infrastructure/cache/ProductCacheDto.java | 40 ++ .../cache/ProductCacheManager.java | 333 +++++++++++ .../infrastructure/cache/RedisCacheLock.java | 83 +++ .../cache/CacheStampedeTest.java | 162 ++++++ .../cache/LocalCacheLockTest.java | 161 ++++++ .../cache/ProductCacheManagerTest.java | 540 ++++++++++++++++++ 11 files changed, 1446 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheConstants.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java index 5f95f456e..f09e675ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductCommandService.java @@ -22,6 +22,7 @@ import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.DEFAULT_PAGE_SIZE; import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.MAX_CACHEABLE_PAGE; +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.buildIdListCacheKey; @Service @@ -241,12 +242,4 @@ private void refreshSingleIdList(Long brandId, ProductSortType sortType, int pag productCacheManager.refreshIdList(cacheKey, () -> productQueryPort.searchProductIds(criteria, pageCriteria)); } - - // ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํ‚ค ์ƒ์„ฑ: products:ids:v1:{brandId|all}:{sortType|LATEST}:{page}:{size} - private String buildIdListCacheKey(Long brandId, ProductSortType sortType, int page, int size) { - String brandPart = brandId != null ? brandId.toString() : "all"; - String sortPart = sortType != null ? sortType.name() : "LATEST"; - return "products:ids:v1:" + brandPart + ":" + sortPart + ":" + page + ":" + size; - } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java new file mode 100644 index 000000000..f6bcd66dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java @@ -0,0 +1,16 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import java.util.function.Supplier; + + +/** + * ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์ง€์šฉ key-level ๋ฝ + * - ๊ฐ™์€ key์— ๋Œ€ํ•œ ๋™์‹œ DB ์กฐํšŒ๋ฅผ 1ํšŒ๋กœ ์ œํ•œ + * - ๊ตฌํ˜„์ฒด: LocalCacheLock (@Primary), RedisCacheLock (๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜์šฉ) + */ +public interface CacheLock { + + T executeWithLock(String key, Supplier loader); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java new file mode 100644 index 000000000..271b80d2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java @@ -0,0 +1,14 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import java.util.List; + + +/** + * ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ๊ฐ’ + * - 2๊ณ„์ธต ์บ์‹œ ์•„ํ‚คํ…์ฒ˜์—์„œ ๋ชฉ๋ก ์บ์‹œ(Layer 1)์˜ ๊ฐ’ + * - ids: ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ƒํ’ˆ ID ๋ชฉ๋ก + * - totalElements: ์ „์ฒด ์ƒํ’ˆ ์ˆ˜ (ํŽ˜์ด์ง€๋„ค์ด์…˜ ์‘๋‹ต์šฉ) + */ +public record IdListCacheEntry(List ids, long totalElements) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java new file mode 100644 index 000000000..756e20837 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java @@ -0,0 +1,46 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + + +/** + * JVM ๋กœ์ปฌ key-level ์บ์‹œ ๋ฝ + * - ConcurrentHashMap + synchronized๋กœ ๊ฐ™์€ key ์š”์ฒญ๋งŒ ์ง๋ ฌํ™” + * - ๋‹ค๋ฅธ key ์š”์ฒญ์€ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ (key ๋‹จ์œ„ ์„ธ๋ฐ€ํ•œ ๋ฝ) + * - ๋‹จ์ผ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ RedisCacheLock์œผ๋กœ @Primary ์ด๋™ + */ +@Primary +@Component +public class LocalCacheLock implements CacheLock { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + + /** + * key-level ๋ฝ ์‹คํ–‰ + * 1. executeWithLock โ€” ๊ฐ™์€ key ์š”์ฒญ์€ ์ง๋ ฌํ™”, ๋‹ค๋ฅธ key๋Š” ๋ณ‘๋ ฌ + */ + + // 1. executeWithLock + @Override + public T executeWithLock(String key, Supplier loader) { + + // key๋ณ„ ๋ฝ ๊ฐ์ฒด ์ƒ์„ฑ (์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ๊ธฐ์กด ๊ฐ์ฒด ๋ฐ˜ํ™˜) + Object lock = locks.computeIfAbsent(key, k -> new Object()); + + // ๊ฐ™์€ key์— ๋Œ€ํ•ด ์ง๋ ฌํ™” ์‹คํ–‰ + synchronized (lock) { + try { + return loader.get(); + } finally { + locks.remove(key); + } + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheConstants.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheConstants.java new file mode 100644 index 000000000..00938ca50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheConstants.java @@ -0,0 +1,50 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.loopers.catalog.product.domain.model.enums.ProductSortType; + +import java.time.Duration; + + +/** + * ์ƒํ’ˆ ์บ์‹œ ์ƒ์ˆ˜ + * - ์บ์‹œ ํ‚ค ์ ‘๋‘์‚ฌ, ๋ฒ„์ „, ํŽ˜์ด์ง€ ๊ธฐ๋ณธ๊ฐ’, TTL ์ •์˜ + * - ์บ์‹œ ํ‚ค ์Šคํ‚ค๋งˆ ๋ฒ„์ „(v1)์œผ๋กœ ๋ฐฐํฌ ์•ˆ์ „์„ฑ ํ™•๋ณด + * - ์บ์‹œ ํ‚ค ์ƒ์„ฑ ์œ ํ‹ธ๋ฆฌํ‹ฐ (์ค‘๋ณต ๋ฐฉ์ง€) + */ +public final class ProductCacheConstants { + + // ์บ์‹œ ํ‚ค ๋ฒ„์ „ + public static final String CACHE_VERSION = "v1"; + + // ์ƒ์„ธ ์บ์‹œ ํ‚ค ์ ‘๋‘์‚ฌ โ€” product:v1:{productId} + public static final String DETAIL_KEY_PREFIX = "product:" + CACHE_VERSION + ":"; + + // ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํ‚ค ์ ‘๋‘์‚ฌ โ€” products:ids:v1:{brand|all}:{sort}:{page}:{size} + public static final String ID_LIST_KEY_PREFIX = "products:ids:" + CACHE_VERSION + ":"; + + // ์บ์‹œ ์ ์šฉ ๊ธฐ๋ณธ ํŽ˜์ด์ง€ ํฌ๊ธฐ + public static final int DEFAULT_PAGE_SIZE = 20; + + // ์บ์‹œ ์ ์šฉ ์ตœ๋Œ€ ํŽ˜์ด์ง€ (0-based, page 0 ~ 1) + public static final int MAX_CACHEABLE_PAGE = 2; + + // ID ๋ฆฌ์ŠคํŠธ TTL (3๋ถ„) + public static final Duration ID_LIST_TTL = Duration.ofMinutes(3); + + // ์ƒ์„ธ ์บ์‹œ TTL (2๋ถ„) + public static final Duration DETAIL_TTL = Duration.ofMinutes(2); + + + // ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํ‚ค ์ƒ์„ฑ: products:ids:v1:{brandId|all}:{sortType|LATEST}:{page}:{size} + public static String buildIdListCacheKey(Long brandId, ProductSortType sortType, int page, int size) { + String brandPart = brandId != null ? brandId.toString() : "all"; + String sortPart = sortType != null ? sortType.name() : "LATEST"; + return ID_LIST_KEY_PREFIX + brandPart + ":" + sortPart + ":" + page + ":" + size; + } + + + private ProductCacheConstants() { + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java new file mode 100644 index 000000000..841a25f6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java @@ -0,0 +1,40 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; +import com.loopers.catalog.product.application.dto.out.ProductOutDto; + +import java.math.BigDecimal; + + +/** + * ์ƒํ’ˆ ์บ์‹œ DTO (PLP + PDP ๊ณต์šฉ) + * - ์ƒ์„ธ ์บ์‹œ ๊ฐ’์œผ๋กœ ์ €์žฅ๋˜๋ฉฐ, PLP/PDP ์‘๋‹ต์— ํ•„์š”ํ•œ ๋ชจ๋“  ํ•„๋“œ ํฌํ•จ + * - Read Model์—์„œ ์ง์ ‘ projectionํ•˜์—ฌ ์ƒ์„ฑ + * + * - id: ์ƒํ’ˆ ID + * - brandId: ๋ธŒ๋žœ๋“œ ID + * - brandName: ๋ธŒ๋žœ๋“œ๋ช… + * - name: ์ƒํ’ˆ๋ช… + * - price: ๊ฐ€๊ฒฉ + * - stock: ์žฌ๊ณ  + * - description: ์ƒํ’ˆ ์„ค๋ช… + * - likeCount: ์ข‹์•„์š” ์ˆ˜ + */ +public record ProductCacheDto( + Long id, Long brandId, String brandName, String name, + BigDecimal price, Long stock, String description, Long likeCount +) { + + // 1. PLP ์‘๋‹ต์šฉ ๋ณ€ํ™˜ (description ์ œ์™ธ) + public ProductOutDto toProductOutDto() { + return new ProductOutDto(id, brandId, brandName, name, price, stock, likeCount); + } + + + // 2. PDP ์‘๋‹ต์šฉ ๋ณ€ํ™˜ (์ „์ฒด ํ•„๋“œ) + public ProductDetailOutDto toProductDetailOutDto() { + return new ProductDetailOutDto(id, brandId, brandName, name, price, stock, description, likeCount); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java new file mode 100644 index 000000000..c11acec5e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java @@ -0,0 +1,333 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.config.redis.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.DETAIL_KEY_PREFIX; +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.DETAIL_TTL; +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.ID_LIST_TTL; + + +/** + * ์ƒํ’ˆ ์บ์‹œ ๊ด€๋ฆฌ์ž + * - Redis ๊ธฐ๋ฐ˜ Cache-Aside ํŒจํ„ด ์ง€์› + * - ๋ชจ๋“  ๋ฉ”์„œ๋“œ๋Š” Redis ์žฅ์•  ์‹œ ์˜ˆ์™ธ๋ฅผ ๊ฒฉ๋ฆฌํ•˜๊ณ  ๋กœ๊น…๋งŒ ์ˆ˜ํ–‰ + * - ์ฝ๊ธฐ: replica-preferred, ์“ฐ๊ธฐ/์‚ญ์ œ: master + * - ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ: CacheLock + PER (Probabilistic Early Refresh) + * + * 1. get(key, Class) โ€” ๋‹จ์ˆœ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + * 2. get(key, TypeReference) โ€” ์ œ๋„ค๋ฆญ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + * 3. put(key, value, ttl) โ€” ์บ์‹œ ์ €์žฅ (TTL jitter ํฌํ•จ) + * 4. evict(key) โ€” ๋‹จ์ผ ํ‚ค ์‚ญ์ œ + * 5. getOrLoad(key, type, ttl, loader) โ€” Cache-Aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + * 6. getOrLoadWithPer(key, type, ttl, loader) โ€” getOrLoad + PER (TTL ์ž„๋ฐ• ์‹œ ํ™•๋ฅ ์  ๊ฐฑ์‹ ) + * 7. refreshProductDetail(productId, loader) โ€” ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through + * 8. refreshIdList(cacheKey, loader) โ€” ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through (๋‹จ๊ฑด) + * 9. deleteProductDetail(productId) โ€” ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ + * 10. mgetProductDetails(productIds) โ€” ์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ƒ์„ธ ์ผ๊ด„ ์กฐํšŒ (MGET) + */ +@Slf4j +@Component +public class ProductCacheManager { + + // redis + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + // util + private final ObjectMapper objectMapper; + // cache + private final CacheLock cacheLock; + // PER ๋น„๋™๊ธฐ ๊ฐฑ์‹  ์ „์šฉ ์Šค๋ ˆ๋“œ ํ’€ (ForkJoinPool ๊ณ ๊ฐˆ ๋ฐฉ์ง€) + private final ExecutorService perExecutor = Executors.newFixedThreadPool(3); + + + public ProductCacheManager( + RedisTemplate readTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate writeTemplate, + ObjectMapper objectMapper, + CacheLock cacheLock + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + this.cacheLock = cacheLock; + } + + + // 1. ๋‹จ์ˆœ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + public Optional get(String key, Class type) { + + try { + // replica์—์„œ JSON ๋ฌธ์ž์—ด ์กฐํšŒ + String json = readTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + + // JSON โ†’ ๊ฐ์ฒด ์—ญ์ง๋ ฌํ™” + return Optional.of(objectMapper.readValue(json, type)); + } catch (Exception e) { + log.warn("์บ์‹œ ์กฐํšŒ ์‹คํŒจ. key={}", key, e); + return Optional.empty(); + } + } + + + // 2. ์ œ๋„ค๋ฆญ ํƒ€์ž… ์บ์‹œ ์กฐํšŒ + public Optional get(String key, TypeReference typeRef) { + + try { + // replica์—์„œ JSON ๋ฌธ์ž์—ด ์กฐํšŒ + String json = readTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + + // JSON โ†’ ์ œ๋„ค๋ฆญ ํƒ€์ž… ์—ญ์ง๋ ฌํ™” + return Optional.of(objectMapper.readValue(json, typeRef)); + } catch (Exception e) { + log.warn("์บ์‹œ ์กฐํšŒ ์‹คํŒจ. key={}", key, e); + return Optional.empty(); + } + } + + + // 3. ์บ์‹œ ์ €์žฅ (TTL jitter ํฌํ•จ) + public void put(String key, Object value, Duration ttl) { + + try { + // ๊ฐ์ฒด โ†’ JSON ์ง๋ ฌํ™” + String json = objectMapper.writeValueAsString(value); + + // jitter ์ ์šฉ๋œ TTL๋กœ master์— ์ €์žฅ + Duration jitteredTtl = applyJitter(ttl); + writeTemplate.opsForValue().set(key, json, jitteredTtl); + } catch (Exception e) { + log.warn("์บ์‹œ ์ €์žฅ ์‹คํŒจ. key={}", key, e); + } + } + + + // 4. ๋‹จ์ผ ํ‚ค ์‚ญ์ œ + public void evict(String key) { + + try { + writeTemplate.delete(key); + } catch (Exception e) { + log.warn("์บ์‹œ ์‚ญ์ œ ์‹คํŒจ. key={}", key, e); + } + } + + + // 5. Cache-Aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ (CacheLock + double-check) + public T getOrLoad(String key, Class type, Duration ttl, Supplier loader) { + + // ์บ์‹œ ์กฐํšŒ + Optional cached = get(key, type); + if (cached.isPresent()) { + return cached.get(); + } + + // ์บ์‹œ ๋ฏธ์Šค โ†’ ๋ฝ ํš๋“ ํ›„ DB ์กฐํšŒ (1ํšŒ๋งŒ) + return cacheLock.executeWithLock(key, () -> { + + // double-check (๋Œ€๊ธฐ ์ค‘ ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ๊ฐ€ ์บ์‹œ ์ €์žฅํ–ˆ์„ ์ˆ˜ ์žˆ์Œ) + Optional doubleCheck = get(key, type); + if (doubleCheck.isPresent()) { + return doubleCheck.get(); + } + + // DB ์กฐํšŒ + ์บ์‹œ ์ €์žฅ (null์ด๋ฉด ์บ์‹œ์— ์ €์žฅํ•˜์ง€ ์•Š์Œ) + T value = loader.get(); + if (value != null) { + put(key, value, ttl); + } + return value; + }); + } + + + // 6. Cache-Aside + PER (Probabilistic Early Refresh) + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + public T getOrLoadWithPer(String key, Class type, Duration ttl, Supplier loader) { + + // ์บ์‹œ ์กฐํšŒ + Optional cached = get(key, type); + if (cached.isPresent()) { + + // PER: TTL ์ž”์—ฌ ์‹œ๊ฐ„ ํ™•์ธ โ†’ ์ž„๋ฐ• ์‹œ ํ™•๋ฅ ์  ๋น„๋™๊ธฐ ๊ฐฑ์‹  + if (shouldEarlyRefresh(key, ttl)) { + CompletableFuture.runAsync(() -> { + try { + T fresh = loader.get(); + put(key, fresh, ttl); + } catch (Exception e) { + log.warn("PER ๋น„๋™๊ธฐ ๊ฐฑ์‹  ์‹คํŒจ. key={}", key, e); + } + }, perExecutor); + } + + return cached.get(); + } + + // ์บ์‹œ ๋ฏธ์Šค โ†’ ๋ฝ + double-check + return cacheLock.executeWithLock(key, () -> { + + // double-check (๋Œ€๊ธฐ ์ค‘ ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ๊ฐ€ ์บ์‹œ ์ €์žฅํ–ˆ์„ ์ˆ˜ ์žˆ์Œ) + Optional doubleCheck = get(key, type); + if (doubleCheck.isPresent()) { + return doubleCheck.get(); + } + + // DB ์กฐํšŒ + ์บ์‹œ ์ €์žฅ (null์ด๋ฉด ์บ์‹œ์— ์ €์žฅํ•˜์ง€ ์•Š์Œ) + T value = loader.get(); + if (value != null) { + put(key, value, ttl); + } + return value; + }); + } + + + // 7. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through (Supplier ๊ธฐ๋ฐ˜) + public void refreshProductDetail(Long productId, Supplier loader) { + + try { + // Read Model์—์„œ ProductCacheDto ๋กœ๋“œ + ProductCacheDto dto = loader.get(); + if (dto == null) { + return; + } + + // ์ƒ์„ธ ์บ์‹œ์— ์ €์žฅ + put(DETAIL_KEY_PREFIX + productId, dto, DETAIL_TTL); + } catch (Exception e) { + log.warn("์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through ์‹คํŒจ. productId={}", productId, e); + } + } + + + // 8. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through (๋‹จ๊ฑด, Supplier ๊ธฐ๋ฐ˜) + public void refreshIdList(String cacheKey, Supplier loader) { + + try { + // DB์—์„œ ํ•ด๋‹น ์กฐ๊ฑด์˜ ID ๋ฆฌ์ŠคํŠธ ์žฌ์กฐํšŒ + IdListCacheEntry entry = loader.get(); + if (entry == null) { + return; + } + + // ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ์— ์ €์žฅ + put(cacheKey, entry, ID_LIST_TTL); + } catch (Exception e) { + log.warn("ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through ์‹คํŒจ. key={}", cacheKey, e); + } + } + + + // 9. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ (์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์˜ˆ์™ธ์  ์‚ฌ์šฉ) + public void deleteProductDetail(Long productId) { + evict(DETAIL_KEY_PREFIX + productId); + } + + + // 10. ์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ƒ์„ธ ์ผ๊ด„ ์กฐํšŒ (MGET) + public List mgetProductDetails(List productIds) { + + try { + // ์ƒ์„ธ ์บ์‹œ ํ‚ค ๋ชฉ๋ก ์ƒ์„ฑ + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + + // MGET์œผ๋กœ ์ผ๊ด„ ์กฐํšŒ (null ํฌํ•จ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜) + List jsonValues = readTemplate.opsForValue().multiGet(keys); + if (jsonValues == null) { + return productIds.stream().map(id -> (ProductCacheDto) null).toList(); + } + + // JSON โ†’ ProductCacheDto ์—ญ์ง๋ ฌํ™” (์‹คํŒจ ์‹œ null) + return jsonValues.stream() + .map(json -> { + if (json == null) { + return null; + } + try { + return objectMapper.readValue(json, ProductCacheDto.class); + } catch (Exception e) { + log.warn("MGET ์—ญ์ง๋ ฌํ™” ์‹คํŒจ", e); + return null; + } + }) + .toList(); + } catch (Exception e) { + log.warn("MGET ์บ์‹œ ์กฐํšŒ ์‹คํŒจ. productIds={}", productIds, e); + // ์ „์ฒด ์‹คํŒจ ์‹œ null ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ (partial miss ์ฒ˜๋ฆฌ๋กœ fallback) + return productIds.stream().map(id -> (ProductCacheDto) null).toList(); + } + } + + + /** + * private method + * - PER ํŒ์ •: TTL์˜ ๋งˆ์ง€๋ง‰ 20% ๊ตฌ๊ฐ„์—์„œ ํ™•๋ฅ ์  ๊ฐฑ์‹  + * - TTL jitter ์ ์šฉ: base TTL์— 0~10% ๋žœ๋ค ์ถ”๊ฐ€ + */ + + // PER ํŒ์ •: TTL์˜ ๋งˆ์ง€๋ง‰ 20% ๊ตฌ๊ฐ„์—์„œ ํ™•๋ฅ ์  ๊ฐฑ์‹  + private boolean shouldEarlyRefresh(String key, Duration baseTtl) { + + try { + // ๋‚จ์€ TTL ์กฐํšŒ (๋ฐ€๋ฆฌ์ดˆ) + Long remainMs = readTemplate.getExpire(key, TimeUnit.MILLISECONDS); + if (remainMs == null || remainMs <= 0) { + return false; + } + + // threshold = base TTL์˜ 20% + long thresholdMs = baseTtl.toMillis() / 5; + if (remainMs > thresholdMs) { + return false; + } + + // ๋‚จ์€ ์‹œ๊ฐ„์ด ์ ์„์ˆ˜๋ก ๊ฐฑ์‹  ํ™•๋ฅ  ์ฆ๊ฐ€ (์„ ํ˜•) + double probability = 1.0 - ((double) remainMs / thresholdMs); + return ThreadLocalRandom.current().nextDouble() < probability; + } catch (Exception e) { + return false; + } + } + + + // TTL jitter ์ ์šฉ: base TTL + random(0, base TTL * 0.1) + Duration applyJitter(Duration baseTtl) { + + long baseMs = baseTtl.toMillis(); + long jitterBound = baseMs / 10; + + // jitter ๋ฒ”์œ„๊ฐ€ 0 ์ดํ•˜์ด๋ฉด ์›๋ž˜ TTL ๋ฐ˜ํ™˜ + if (jitterBound <= 0) { + return baseTtl; + } + + long jitterMs = ThreadLocalRandom.current().nextLong(jitterBound + 1); + return Duration.ofMillis(baseMs + jitterMs); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java new file mode 100644 index 000000000..381adae0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java @@ -0,0 +1,83 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.loopers.config.redis.RedisConfig; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.function.Supplier; + + +/** + * Redis SETNX ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ์บ์‹œ ๋ฝ + * - ๋ถ„์‚ฐ ํ™˜๊ฒฝ(multi-JVM)์—์„œ ์‚ฌ์šฉ + * - ํ˜„์žฌ๋Š” ๋Œ€๊ธฐ ์ƒํƒœ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ @Primary ์ด๋™ + */ +@Component +public class RedisCacheLock implements CacheLock { + + // redis + private final RedisTemplate redisTemplate; + + private static final Duration LOCK_TTL = Duration.ofSeconds(5); + private static final long WAIT_MILLIS = 50; + private static final int MAX_WAIT_RETRIES = 20; + + + public RedisCacheLock( + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate + ) { + this.redisTemplate = redisTemplate; + } + + + /** + * Redis SETNX ๊ธฐ๋ฐ˜ ๋ถ„์‚ฐ ๋ฝ ์‹คํ–‰ + * 1. executeWithLock โ€” SETNX๋กœ ๋ฝ ํš๋“ ํ›„ loader ์‹คํ–‰, ์‹คํŒจ ์‹œ ๋Œ€๊ธฐ ํ›„ ์žฌ์‹œ๋„ + */ + + // 1. executeWithLock + @Override + public T executeWithLock(String key, Supplier loader) { + + // ๋ฝ ํ‚ค ์ƒ์„ฑ + String lockKey = key + ":lock"; + + // SETNX๋กœ ๋ฝ ํš๋“ ์‹œ๋„ (TTL 5์ดˆ) + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "1", LOCK_TTL); + + try { + if (Boolean.TRUE.equals(acquired)) { + // ๋ฝ ํš๋“ ์„ฑ๊ณต โ†’ loader ์‹คํ–‰ + return loader.get(); + } else { + // ๋ฝ ๋ฏธํš๋“ โ†’ ๋ฝ ํ•ด์ œ ๋Œ€๊ธฐ ํ›„ loader ์žฌ์‹คํ–‰ (double-check ํฌํ•จ) + waitForLockRelease(lockKey); + return loader.get(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return loader.get(); + } finally { + // ๋ฝ ํš๋“ ์„ฑ๊ณตํ•œ ๊ฒฝ์šฐ์—๋งŒ ํ•ด์ œ + if (Boolean.TRUE.equals(acquired)) { + redisTemplate.delete(lockKey); + } + } + } + + + // ๋ฝ ํ•ด์ œ ๋Œ€๊ธฐ (๋ฝ ๋ณด์œ  ์Šค๋ ˆ๋“œ์˜ ์บ์‹œ ์ €์žฅ ์™„๋ฃŒ๋ฅผ ๊ธฐ๋‹ค๋ฆผ) + private void waitForLockRelease(String lockKey) throws InterruptedException { + for (int i = 0; i < MAX_WAIT_RETRIES; i++) { + Thread.sleep(WAIT_MILLIS); + if (!Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))) { + return; + } + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java new file mode 100644 index 000000000..76d82a796 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java @@ -0,0 +1,162 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest +@ActiveProfiles("test") +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@DisplayName("CacheStampede ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") +class CacheStampedeTest { + + @Autowired + private ProductCacheManager productCacheManager; + + @Autowired + private RedisCleanUp redisCleanUp; + + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + + @Nested + @DisplayName("getOrLoad() ์Šคํƒฌํ”ผ๋“œ") + class GetOrLoadStampedeTest { + + @Test + @DisplayName("[getOrLoad()] single-key ์Šคํƒฌํ”ผ๋“œ - ์บ์‹œ ๋ฏธ์Šค ์ƒํƒœ์—์„œ 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> loader ํ˜ธ์ถœ ์ตœ์†Œํ™” (์ด์ƒ: 1ํšŒ)") + void singleKeyStampede_loaderMinimized() throws InterruptedException { + + // Arrange + int threadCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger loaderCallCount = new AtomicInteger(0); + String key = "product:stampede:1"; + Duration ttl = Duration.ofMinutes(10); + ProductDetailOutDto expected = new ProductDetailOutDto( + 1L, 1L, "Brand", "Product", + new BigDecimal("10000"), 10L, "desc", 0L + ); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + readyLatch.countDown(); + try { + startLatch.await(); + productCacheManager.getOrLoad( + key, ProductDetailOutDto.class, ttl, + () -> { + loaderCallCount.incrementAndGet(); + + // DB ์กฐํšŒ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return expected; + } + ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + // Assert โ€” loader ํ˜ธ์ถœ ์ตœ์†Œํ™” (์ด์ƒ: 1ํšŒ, ๋ ˆ์ด์Šค ์ปจ๋””์…˜ ํ—ˆ์šฉ: <= 2) + assertThat(loaderCallCount.get()).isLessThanOrEqualTo(2); + } + + + @Test + @DisplayName("[getOrLoad()] ์บ์‹œ ํžˆํŠธ ์ƒํƒœ์—์„œ 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> loader 0ํšŒ ํ˜ธ์ถœ") + void cacheHitStampede_loaderNotCalled() throws InterruptedException { + + // Arrange + String key = "product:stampede:2"; + Duration ttl = Duration.ofMinutes(10); + ProductDetailOutDto cached = new ProductDetailOutDto( + 2L, 1L, "Brand", "CachedProduct", + new BigDecimal("20000"), 20L, "cached", 5L + ); + productCacheManager.put(key, cached, ttl); + + int threadCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger loaderCallCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + readyLatch.countDown(); + try { + startLatch.await(); + productCacheManager.getOrLoad( + key, ProductDetailOutDto.class, ttl, + () -> { + loaderCallCount.incrementAndGet(); + return new ProductDetailOutDto( + 99L, 99L, "New", "New", + new BigDecimal("99999"), 99L, "new", 99L + ); + } + ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + // Assert โ€” ์บ์‹œ ํžˆํŠธ์ด๋ฏ€๋กœ loader 0ํšŒ ํ˜ธ์ถœ + assertThat(loaderCallCount.get()).isEqualTo(0); + } + + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java new file mode 100644 index 000000000..6d9665970 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java @@ -0,0 +1,161 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +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.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +@DisplayName("LocalCacheLock ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") +class LocalCacheLockTest { + + private LocalCacheLock localCacheLock; + + + @BeforeEach + void setUp() { + localCacheLock = new LocalCacheLock(); + } + + + @Nested + @DisplayName("executeWithLock()") + class ExecuteWithLockTest { + + @Test + @DisplayName("[executeWithLock()] ๊ฐ™์€ key 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> ์ง๋ ฌ ์‹คํ–‰์œผ๋กœ loader 100ํšŒ ํ˜ธ์ถœ. ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋Š” CacheManager double-check์—์„œ ์ˆ˜ํ–‰") + void sameKeyConcurrentRequests_loaderCalledOnce() throws InterruptedException { + + // Arrange + int threadCount = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger loaderCallCount = new AtomicInteger(0); + String key = "same-key"; + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + readyLatch.countDown(); + try { + startLatch.await(); + localCacheLock.executeWithLock(key, () -> { + loaderCallCount.incrementAndGet(); + + // loader ์‹คํ–‰์— ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฌ๋Š” ์ƒํ™ฉ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return "result"; + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + // ๋ชจ๋“  ์Šค๋ ˆ๋“œ ์ค€๋น„ ์™„๋ฃŒ ํ›„ ๋™์‹œ ์‹œ์ž‘ + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + // Assert โ€” ๋ฝ์— ์˜ํ•ด loader๋Š” ์ง๋ ฌ ์‹คํ–‰๋˜๋ฏ€๋กœ 100ํšŒ ํ˜ธ์ถœ (๋Œ€๊ธฐ ํ›„ ์ˆœ์ฐจ ์‹คํ–‰) + // LocalCacheLock์€ ๊ฒฐ๊ณผ ์บ์‹ฑ์„ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ๊ฐ ์Šค๋ ˆ๋“œ๊ฐ€ ์ˆœ์„œ๋Œ€๋กœ loader ํ˜ธ์ถœ + // ์‹ค์ œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋Š” ProductCacheManager์˜ double-check ํŒจํ„ด์—์„œ ์ˆ˜ํ–‰ + assertThat(loaderCallCount.get()).isEqualTo(threadCount); + } + + + @Test + @DisplayName("[executeWithLock()] ๋‹ค๋ฅธ key ๋™์‹œ ์š”์ฒญ -> ๊ฐ๊ฐ ๋…๋ฆฝ ์‹คํ–‰. ์„œ๋กœ ๋ธ”๋กœํ‚นํ•˜์ง€ ์•Š์Œ") + void differentKeysConcurrentRequests_independentExecution() throws InterruptedException { + + // Arrange + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger concurrentCount = new AtomicInteger(0); + AtomicInteger maxConcurrent = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + String key = "key-" + i; + executor.submit(() -> { + readyLatch.countDown(); + try { + startLatch.await(); + localCacheLock.executeWithLock(key, () -> { + + // ๋™์‹œ ์‹คํ–‰ ์Šค๋ ˆ๋“œ ์ˆ˜ ์ถ”์  + int current = concurrentCount.incrementAndGet(); + maxConcurrent.updateAndGet(max -> Math.max(max, current)); + + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + concurrentCount.decrementAndGet(); + return "result"; + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + // Assert โ€” ๋‹ค๋ฅธ key์ด๋ฏ€๋กœ ๋ณ‘๋ ฌ ์‹คํ–‰๋จ (์ตœ๋Œ€ ๋™์‹œ ์‹คํ–‰ ์ˆ˜ > 1) + assertThat(maxConcurrent.get()).isGreaterThan(1); + } + + + @Test + @DisplayName("[executeWithLock()] loader ์˜ˆ์™ธ ๋ฐœ์ƒ -> ๋ฝ ์ •์ƒ ํ•ด์ œ. ์˜ˆ์™ธ ์ „ํŒŒ") + void loaderThrowsException_lockReleased_exceptionPropagated() { + + // Arrange + String key = "error-key"; + RuntimeException expectedException = new RuntimeException("loader ์‹คํŒจ"); + + // Act & Assert โ€” ์˜ˆ์™ธ๊ฐ€ ์ „ํŒŒ๋จ + assertThatThrownBy(() -> + localCacheLock.executeWithLock(key, () -> { + throw expectedException; + }) + ).isEqualTo(expectedException); + + // ๋ฝ ํ•ด์ œ ๊ฒ€์ฆ: ์ดํ›„ ๊ฐ™์€ key๋กœ ์ •์ƒ ์‹คํ–‰ ๊ฐ€๋Šฅ + String result = localCacheLock.executeWithLock(key, () -> "recovered"); + assertThat(result).isEqualTo("recovered"); + } + + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java new file mode 100644 index 000000000..65eb4cbd0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java @@ -0,0 +1,540 @@ +package com.loopers.catalog.product.infrastructure.cache; + + +import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; +import com.loopers.catalog.product.application.dto.out.ProductOutDto; +import com.loopers.catalog.product.application.dto.out.ProductPageOutDto; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +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.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.DETAIL_KEY_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + + +@SpringBootTest +@ActiveProfiles("test") +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@DisplayName("ProductCacheManager ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") +class ProductCacheManagerTest { + + @Autowired + private ProductCacheManager productCacheManager; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + + @Nested + @DisplayName("put() + get()") + class PutAndGetTest { + + @Test + @DisplayName("[put() -> get()] ProductDetailOutDto ์ €์žฅ ํ›„ ์กฐํšŒ -> ๋™์ผํ•œ ๊ฐ์ฒด ๋ฐ˜ํ™˜. ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ์ •์ƒ ๋™์ž‘") + void putAndGetProductDetailOutDto() { + + // Arrange + String key = "product:1"; + ProductDetailOutDto dto = new ProductDetailOutDto( + 1L, 1L, "TestBrand", "TestProduct", + new BigDecimal("59900.00"), 100L, "description", 50L + ); + + // Act + productCacheManager.put(key, dto, Duration.ofMinutes(10)); + Optional result = productCacheManager.get(key, ProductDetailOutDto.class); + + // Assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().id()).isEqualTo(1L), + () -> assertThat(result.get().brandId()).isEqualTo(1L), + () -> assertThat(result.get().brandName()).isEqualTo("TestBrand"), + () -> assertThat(result.get().name()).isEqualTo("TestProduct"), + () -> assertThat(result.get().price()).isEqualByComparingTo(new BigDecimal("59900.00")), + () -> assertThat(result.get().stock()).isEqualTo(100L), + () -> assertThat(result.get().description()).isEqualTo("description"), + () -> assertThat(result.get().likeCount()).isEqualTo(50L) + ); + } + + + @Test + @DisplayName("[put() -> get()] ProductPageOutDto ์ €์žฅ ํ›„ ์กฐํšŒ -> ์ œ๋„ค๋ฆญ ํƒ€์ž… ์—ญ์ง๋ ฌํ™” ์ •์ƒ ๋™์ž‘") + void putAndGetProductPageOutDto() { + + // Arrange + String key = "products:ids:v1:all:LATEST:0:20"; + ProductOutDto productOutDto = new ProductOutDto( + 1L, 1L, "TestBrand", "TestProduct", + new BigDecimal("10000"), 50L, 10L + ); + ProductPageOutDto dto = new ProductPageOutDto( + List.of(productOutDto), 0, 20, 100L + ); + + // Act + productCacheManager.put(key, dto, Duration.ofMinutes(5)); + Optional result = productCacheManager.get(key, ProductPageOutDto.class); + + // Assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().content()).hasSize(1), + () -> assertThat(result.get().content().get(0).id()).isEqualTo(1L), + () -> assertThat(result.get().content().get(0).brandName()).isEqualTo("TestBrand"), + () -> assertThat(result.get().page()).isEqualTo(0), + () -> assertThat(result.get().size()).isEqualTo(20), + () -> assertThat(result.get().totalElements()).isEqualTo(100L) + ); + } + + + @Test + @DisplayName("[put() -> get()] BigDecimal price ์ €์žฅ ํ›„ ์กฐํšŒ -> compareTo ๊ธฐ์ค€ ๋™์ผ ๊ฐ’ ๋ฐ˜ํ™˜") + void putAndGetBigDecimalPrecision() { + + // Arrange + String key = "product:2"; + ProductDetailOutDto dto = new ProductDetailOutDto( + 2L, 1L, "Brand", "Product", + new BigDecimal("99999.99"), 10L, "desc", 0L + ); + + // Act + productCacheManager.put(key, dto, Duration.ofMinutes(10)); + Optional result = productCacheManager.get(key, ProductDetailOutDto.class); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get().price()).isEqualByComparingTo(new BigDecimal("99999.99")); + } + + + @Test + @DisplayName("[put() -> get()] null description ํ•„๋“œ ํฌํ•จ DTO -> ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™” ์ •์ƒ ๋™์ž‘") + void putAndGetWithNullDescription() { + + // Arrange + String key = "product:3"; + ProductDetailOutDto dto = new ProductDetailOutDto( + 3L, 1L, "Brand", "Product", + new BigDecimal("10000"), 10L, null, 0L + ); + + // Act + productCacheManager.put(key, dto, Duration.ofMinutes(10)); + Optional result = productCacheManager.get(key, ProductDetailOutDto.class); + + // Assert + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().id()).isEqualTo(3L), + () -> assertThat(result.get().description()).isNull() + ); + } + + } + + + @Nested + @DisplayName("evict()") + class EvictTest { + + @Test + @DisplayName("[evict()] ์ €์žฅ ํ›„ ์‚ญ์ œ -> get() ์‹œ Optional.empty() ๋ฐ˜ํ™˜") + void evictRemovesCachedValue() { + + // Arrange + String key = "product:10"; + ProductDetailOutDto dto = new ProductDetailOutDto( + 10L, 1L, "Brand", "Product", + new BigDecimal("10000"), 10L, "desc", 0L + ); + productCacheManager.put(key, dto, Duration.ofMinutes(10)); + + // Act + productCacheManager.evict(key); + + // Assert + Optional result = productCacheManager.get(key, ProductDetailOutDto.class); + assertThat(result).isEmpty(); + } + + } + + + @Nested + @DisplayName("TTL") + class TtlTest { + + @Test + @DisplayName("[put()] TTL ์ €์žฅ -> base TTL ยฑ 10% ๋ฒ”์œ„ ๋‚ด TTL ์„ค์ • ํ™•์ธ") + void putSetsTtlWithJitter() { + + // Arrange + String key = "product:ttl"; + Duration baseTtl = Duration.ofMinutes(10); + long baseMs = baseTtl.toMillis(); + long minMs = baseMs; + long maxMs = baseMs + (baseMs / 10); + + // Act + productCacheManager.put(key, "value", baseTtl); + + // Assert + Long remainMs = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + assertThat(remainMs).isNotNull(); + assertThat(remainMs).isBetween(minMs - 1000, maxMs + 1000); + } + + } + + + @Nested + @DisplayName("get() โ€” ๋ฏธ์Šค") + class GetMissTest { + + @Test + @DisplayName("[get()] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ‚ค ์กฐํšŒ -> Optional.empty() ๋ฐ˜ํ™˜. ์˜ˆ์™ธ ์—†์Œ") + void getNonExistentKeyReturnsEmpty() { + + // Act + Optional result = productCacheManager.get( + "nonexistent:key", ProductDetailOutDto.class + ); + + // Assert + assertThat(result).isEmpty(); + } + + } + + + @Nested + @DisplayName("getOrLoad()") + class GetOrLoadTest { + + @Test + @DisplayName("[getOrLoad()] ์บ์‹œ ๋ฏธ์Šค -> loader 1ํšŒ ํ˜ธ์ถœ + ์บ์‹œ ์ €์žฅ") + void getOrLoadCacheMiss_loaderCalledOnce() { + + // Arrange + String key = "product:load:1"; + AtomicInteger loaderCallCount = new AtomicInteger(0); + ProductDetailOutDto expected = new ProductDetailOutDto( + 1L, 1L, "Brand", "Product", + new BigDecimal("10000"), 10L, "desc", 0L + ); + + // Act + ProductDetailOutDto result = productCacheManager.getOrLoad( + key, ProductDetailOutDto.class, Duration.ofMinutes(10), + () -> { + loaderCallCount.incrementAndGet(); + return expected; + } + ); + + // Assert โ€” loader 1ํšŒ ํ˜ธ์ถœ + ์บ์‹œ์— ์ €์žฅ๋จ + assertAll( + () -> assertThat(loaderCallCount.get()).isEqualTo(1), + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(productCacheManager.get(key, ProductDetailOutDto.class)).isPresent() + ); + } + + + @Test + @DisplayName("[getOrLoad()] ์บ์‹œ ํžˆํŠธ -> loader ๋ฏธํ˜ธ์ถœ. ์บ์‹œ๋œ ๊ฐ’ ๋ฐ˜ํ™˜") + void getOrLoadCacheHit_loaderNotCalled() { + + // Arrange + String key = "product:load:2"; + ProductDetailOutDto cached = new ProductDetailOutDto( + 2L, 1L, "Brand", "CachedProduct", + new BigDecimal("20000"), 20L, "cached", 5L + ); + productCacheManager.put(key, cached, Duration.ofMinutes(10)); + AtomicInteger loaderCallCount = new AtomicInteger(0); + + // Act + ProductDetailOutDto result = productCacheManager.getOrLoad( + key, ProductDetailOutDto.class, Duration.ofMinutes(10), + () -> { + loaderCallCount.incrementAndGet(); + return new ProductDetailOutDto( + 99L, 99L, "New", "New", + new BigDecimal("99999"), 99L, "new", 99L + ); + } + ); + + // Assert โ€” loader ๋ฏธํ˜ธ์ถœ, ์บ์‹œ๋œ ๊ฐ’ ๋ฐ˜ํ™˜ + assertAll( + () -> assertThat(loaderCallCount.get()).isEqualTo(0), + () -> assertThat(result.id()).isEqualTo(2L), + () -> assertThat(result.name()).isEqualTo("CachedProduct") + ); + } + + } + + + @Nested + @DisplayName("refreshProductDetail()") + class RefreshProductDetailTest { + + @Test + @DisplayName("[refreshProductDetail()] Supplier๊ฐ€ ProductCacheDto ๋ฐ˜ํ™˜ -> product:v1:{id} ํ‚ค์— ์บ์‹œ ์ €์žฅ") + void refreshProductDetailSuccess() { + + // Arrange + ProductCacheDto dto = new ProductCacheDto( + 1L, 1L, "TestBrand", "TestProduct", + new BigDecimal("10000"), 100L, "desc", 5L + ); + + // Act + productCacheManager.refreshProductDetail(1L, () -> dto); + + // Assert โ€” ์ƒ์„ธ ์บ์‹œ ํ‚ค๋กœ ์ €์žฅ ํ™•์ธ + Optional result = productCacheManager.get(DETAIL_KEY_PREFIX + 1, ProductCacheDto.class); + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().id()).isEqualTo(1L), + () -> assertThat(result.get().brandName()).isEqualTo("TestBrand"), + () -> assertThat(result.get().name()).isEqualTo("TestProduct"), + () -> assertThat(result.get().likeCount()).isEqualTo(5L) + ); + } + + + @Test + @DisplayName("[refreshProductDetail()] Supplier๊ฐ€ null ๋ฐ˜ํ™˜ -> ์บ์‹œ ์ €์žฅํ•˜์ง€ ์•Š์Œ") + void refreshProductDetailNullSkipped() { + + // Act + productCacheManager.refreshProductDetail(999L, () -> null); + + // Assert โ€” ์บ์‹œ์— ์ €์žฅ๋˜์ง€ ์•Š์Œ + Optional result = productCacheManager.get(DETAIL_KEY_PREFIX + 999, ProductCacheDto.class); + assertThat(result).isEmpty(); + } + + } + + + @Nested + @DisplayName("refreshIdList()") + class RefreshIdListTest { + + @Test + @DisplayName("[refreshIdList()] Supplier๊ฐ€ IdListCacheEntry ๋ฐ˜ํ™˜ -> ์ง€์ •๋œ ํ‚ค์— ์บ์‹œ ์ €์žฅ") + void refreshIdListSuccess() { + + // Arrange + String cacheKey = "products:ids:v1:all:LATEST:0:20"; + IdListCacheEntry entry = new IdListCacheEntry(List.of(1L, 2L, 3L), 3); + + // Act + productCacheManager.refreshIdList(cacheKey, () -> entry); + + // Assert + Optional result = productCacheManager.get(cacheKey, IdListCacheEntry.class); + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().ids()).containsExactly(1L, 2L, 3L), + () -> assertThat(result.get().totalElements()).isEqualTo(3) + ); + } + + + @Test + @DisplayName("[refreshIdList()] Supplier๊ฐ€ null ๋ฐ˜ํ™˜ -> ์บ์‹œ ์ €์žฅํ•˜์ง€ ์•Š์Œ") + void refreshIdListNullSkipped() { + + // Arrange + String cacheKey = "products:ids:v1:all:LATEST:0:20"; + + // Act + productCacheManager.refreshIdList(cacheKey, () -> null); + + // Assert + Optional result = productCacheManager.get(cacheKey, IdListCacheEntry.class); + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("[refreshIdList()] ๊ธฐ์กด ์บ์‹œ ์กด์žฌ ์‹œ -> ๋ฎ์–ด์“ฐ๊ธฐ. ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹ ") + void refreshIdListOverwrite() { + + // Arrange โ€” ๊ธฐ์กด ์บ์‹œ ์ €์žฅ + String cacheKey = "products:ids:v1:1:PRICE_ASC:0:20"; + productCacheManager.put(cacheKey, new IdListCacheEntry(List.of(1L), 1), Duration.ofMinutes(5)); + + // Act โ€” ์ƒˆ ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹  + IdListCacheEntry updated = new IdListCacheEntry(List.of(1L, 2L), 2); + productCacheManager.refreshIdList(cacheKey, () -> updated); + + // Assert โ€” ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹  ํ™•์ธ + Optional result = productCacheManager.get(cacheKey, IdListCacheEntry.class); + assertAll( + () -> assertThat(result).isPresent(), + () -> assertThat(result.get().ids()).containsExactly(1L, 2L), + () -> assertThat(result.get().totalElements()).isEqualTo(2) + ); + } + + } + + + @Nested + @DisplayName("deleteProductDetail()") + class DeleteProductDetailTest { + + @Test + @DisplayName("[deleteProductDetail()] ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ -> product:v1:{id} ํ‚ค ์‚ญ์ œ ํ™•์ธ") + void deleteProductDetailSuccess() { + + // Arrange โ€” ์ƒ์„ธ ์บ์‹œ ์ €์žฅ + ProductCacheDto dto = new ProductCacheDto( + 1L, 1L, "Brand", "Product", + new BigDecimal("10000"), 100L, "desc", 0L + ); + productCacheManager.put(DETAIL_KEY_PREFIX + 1, dto, Duration.ofMinutes(10)); + + // Act + productCacheManager.deleteProductDetail(1L); + + // Assert โ€” ์บ์‹œ์—์„œ ์‚ญ์ œ๋จ + Optional result = productCacheManager.get(DETAIL_KEY_PREFIX + 1, ProductCacheDto.class); + assertThat(result).isEmpty(); + } + + + @Test + @DisplayName("[deleteProductDetail()] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ‚ค ์‚ญ์ œ -> ์˜ˆ์™ธ ์—†์ด ์ •์ƒ ์ฒ˜๋ฆฌ") + void deleteProductDetailNonExistent() { + + // Act & Assert โ€” ์˜ˆ์™ธ ์—†์Œ + productCacheManager.deleteProductDetail(999L); + Optional result = productCacheManager.get(DETAIL_KEY_PREFIX + 999, ProductCacheDto.class); + assertThat(result).isEmpty(); + } + + } + + + @Nested + @DisplayName("mgetProductDetails()") + class MgetProductDetailsTest { + + @Test + @DisplayName("[mgetProductDetails()] ์ „์ฒด ํžˆํŠธ -> ๋ชจ๋“  ID์— ๋Œ€ํ•œ ProductCacheDto ๋ฐ˜ํ™˜. ID ์ˆœ์„œ ๋ณด์กด") + void mgetAllHit() { + + // Arrange โ€” 3๊ฑด ์บ์‹œ ์ €์žฅ + for (long i = 1; i <= 3; i++) { + ProductCacheDto dto = new ProductCacheDto( + i, 1L, "Brand", "Product" + i, + new BigDecimal("10000"), 100L, null, 0L + ); + productCacheManager.put(DETAIL_KEY_PREFIX + i, dto, Duration.ofMinutes(10)); + } + + // Act + List result = productCacheManager.mgetProductDetails(List.of(1L, 2L, 3L)); + + // Assert โ€” ์ˆœ์„œ ๋ณด์กด, ๋ชจ๋‘ non-null + assertAll( + () -> assertThat(result).hasSize(3), + () -> assertThat(result.get(0).id()).isEqualTo(1L), + () -> assertThat(result.get(0).name()).isEqualTo("Product1"), + () -> assertThat(result.get(1).id()).isEqualTo(2L), + () -> assertThat(result.get(2).id()).isEqualTo(3L) + ); + } + + + @Test + @DisplayName("[mgetProductDetails()] partial miss -> ํžˆํŠธ๋œ ํ•ญ๋ชฉ์€ ๋ฐ˜ํ™˜, miss๋œ ํ•ญ๋ชฉ์€ null. ์œ„์น˜ ๋ณด์กด") + void mgetPartialMiss() { + + // Arrange โ€” id=1๋งŒ ์บ์‹œ, id=2๋Š” ๋ฏธ์บ์‹œ + ProductCacheDto dto1 = new ProductCacheDto( + 1L, 1L, "Brand", "Product1", + new BigDecimal("10000"), 100L, null, 0L + ); + productCacheManager.put(DETAIL_KEY_PREFIX + 1, dto1, Duration.ofMinutes(10)); + + // Act + List result = productCacheManager.mgetProductDetails(List.of(1L, 2L)); + + // Assert โ€” id=1 ํžˆํŠธ, id=2 null + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0)).isNotNull(), + () -> assertThat(result.get(0).id()).isEqualTo(1L), + () -> assertThat(result.get(1)).isNull() + ); + } + + + @Test + @DisplayName("[mgetProductDetails()] ์ „์ฒด miss -> ๋ชจ๋“  ํ•ญ๋ชฉ์ด null์ธ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜") + void mgetAllMiss() { + + // Act + List result = productCacheManager.mgetProductDetails(List.of(100L, 200L)); + + // Assert + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0)).isNull(), + () -> assertThat(result.get(1)).isNull() + ); + } + + + @Test + @DisplayName("[mgetProductDetails()] ๋นˆ ID ๋ชฉ๋ก -> ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜") + void mgetEmptyIds() { + + // Act + List result = productCacheManager.mgetProductDetails(List.of()); + + // Assert + assertThat(result).isEmpty(); + } + + } + +} From 0161e4db59df2a94e443817178953f5dae2b90b6 Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:22:36 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20Cache-Aside=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductQueryFacade ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์ ์šฉ - ProductQueryService ์ƒํ’ˆ ๋ชฉ๋ก ์บ์‹œ ์ ์šฉ - ProductCommandFacade ์บ์‹œ write-through (์ˆ˜์ •/์‚ญ์ œ ์‹œ ์ฆ‰์‹œ ์žฌ์ ์žฌ) - ProductCommandFacadeTest, ProductQueryFacadeTest, ProductQueryServiceTest ์บ์‹œ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - ProductControllerE2ETest ์บ์‹œ ๋ฌดํšจํ™” E2E ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ - round5-docs/05-to-be-cache-measurement.md ์บ์‹œ ์ธก์ • ๊ฒฐ๊ณผ - round5-docs/05-to-be-cache-visualization.html ์‹œ๊ฐํ™” - round5-docs/06-2layer-cache-implementation-design.md 2๊ณ„์ธต ์บ์‹œ ์„ค๊ณ„ - round5-docs/07-cache-eviction-analysis.md ์บ์‹œ ๋ฌดํšจํ™” ๋ถ„์„ - docs/todo/cache-event-driven-refresh.md ํ›„์† TODO Co-Authored-By: Claude Opus 4.6 --- .../facade/ProductCommandFacade.java | 29 +- .../facade/ProductQueryFacade.java | 34 +- .../service/ProductQueryService.java | 180 ++++- .../facade/ProductCommandFacadeTest.java | 33 +- .../facade/ProductQueryFacadeTest.java | 44 +- .../service/ProductQueryServiceTest.java | 296 +++++++- .../interfaces/ProductControllerE2ETest.java | 36 + docs/todo/cache-event-driven-refresh.md | 85 +++ round5-docs/05-to-be-cache-measurement.md | 197 +++++ round5-docs/05-to-be-cache-visualization.html | 691 ++++++++++++++++++ .../06-2layer-cache-implementation-design.md | 527 +++++++++++++ round5-docs/07-cache-eviction-analysis.md | 684 +++++++++++++++++ 12 files changed, 2742 insertions(+), 94 deletions(-) create mode 100644 docs/todo/cache-event-driven-refresh.md create mode 100644 round5-docs/05-to-be-cache-measurement.md create mode 100644 round5-docs/05-to-be-cache-visualization.html create mode 100644 round5-docs/06-2layer-cache-implementation-design.md create mode 100644 round5-docs/07-cache-eviction-analysis.md diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductCommandFacade.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductCommandFacade.java index d719e2730..70f36517b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductCommandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductCommandFacade.java @@ -9,6 +9,7 @@ import com.loopers.catalog.product.application.service.ProductCommandService; import com.loopers.catalog.product.application.service.ProductQueryService; import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.enums.ProductSortType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,8 +45,15 @@ public AdminProductDetailOutDto createProduct(AdminProductCreateInDto inDto) { // ์ƒํ’ˆ ์ƒ์„ฑ Product savedProduct = productCommandService.createProduct(inDto); - // DTO ๋ณ€ํ™˜ (๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ) - return AdminProductDetailOutDto.from(savedProduct, brand.getName().value()); + // Read Model ๋™๊ธฐํ™” + productCommandService.syncReadModel(savedProduct, brand.getName().value()); + + // write-through: ์ƒ์„ธ ์บ์‹œ + ๋ชจ๋“  ์ •๋ ฌ ID ๋ฆฌ์ŠคํŠธ + productCommandService.refreshProductDetailCache(savedProduct.getId()); + productCommandService.refreshIdListCacheForAllSorts(savedProduct.getBrandId()); + + // Read Model ์žฌ์กฐํšŒ (likeCount๋Š” Read Model์ด SoT) + return productQueryService.getAdminProductDetail(savedProduct.getId()); } @@ -62,8 +70,15 @@ public AdminProductDetailOutDto updateProduct(Long id, AdminProductUpdateInDto i // ๋ธŒ๋žœ๋“œ ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ์‘๋‹ต) Brand brand = brandQueryService.getBrandById(updatedProduct.getBrandId()); - // DTO ๋ณ€ํ™˜ - return AdminProductDetailOutDto.from(updatedProduct, brand.getName().value()); + // Read Model ๋™๊ธฐํ™” + productCommandService.syncReadModel(updatedProduct, brand.getName().value()); + + // write-through: ์ƒ์„ธ ์บ์‹œ + PRICE_ASC ์ •๋ ฌ ID ๋ฆฌ์ŠคํŠธ (๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ ์˜ํ–ฅ) + productCommandService.refreshProductDetailCache(id); + productCommandService.refreshIdListCacheForSort(updatedProduct.getBrandId(), ProductSortType.PRICE_ASC); + + // Read Model ์žฌ์กฐํšŒ (likeCount๋Š” Read Model์ด SoT) + return productQueryService.getAdminProductDetail(id); } @@ -77,6 +92,12 @@ public void deleteProduct(Long id) { // ์ƒํ’ˆ ์‚ญ์ œ productCommandService.deleteProduct(product); + // ์ƒ์„ธ ์บ์‹œ: evict (์‚ญ์ œ๋œ ์ƒํ’ˆ์ด๋ฏ€๋กœ) + productCommandService.deleteProductDetailCache(id); + + // ID ๋ฆฌ์ŠคํŠธ: write-through (๋ชจ๋“  ์ •๋ ฌ โ€” ์‚ญ์ œ ์ƒํ’ˆ ์ œ๊ฑฐ) + productCommandService.refreshIdListCacheForAllSorts(product.getBrandId()); + // ์ƒํ’ˆ ์ข‹์•„์š” ์ •๋ฆฌ (Cross-BC ๋ถ€์ˆ˜ํšจ๊ณผ) productCommandService.deleteAllProductLikes(product.getId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductQueryFacade.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductQueryFacade.java index 2a8806299..96aa4b00e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductQueryFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/facade/ProductQueryFacade.java @@ -1,12 +1,7 @@ package com.loopers.catalog.product.application.facade; -import com.loopers.catalog.brand.application.service.BrandQueryService; -import com.loopers.catalog.brand.domain.model.Brand; -import com.loopers.catalog.product.application.dto.out.AdminProductDetailOutDto; -import com.loopers.catalog.product.application.dto.out.AdminProductPageOutDto; -import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; -import com.loopers.catalog.product.application.dto.out.ProductPageOutDto; +import com.loopers.catalog.product.application.dto.out.*; import com.loopers.catalog.product.application.service.ProductQueryService; import com.loopers.catalog.product.domain.model.Product; import com.loopers.catalog.product.domain.model.enums.ProductSortType; @@ -23,31 +18,24 @@ public class ProductQueryFacade { // service private final ProductQueryService productQueryService; - private final BrandQueryService brandQueryService; /** * ์ƒํ’ˆ ์กฐํšŒ ํŒŒ์‚ฌ๋“œ * 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (ํ™œ์„ฑ ์ƒํ’ˆ๋งŒ, ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ) * 2. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ, ์ •๋ ฌ, ํŽ˜์ด์ง€๋„ค์ด์…˜) - * 3. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ) + * 3. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, Read Model ๊ธฐ๋ฐ˜ โ€” ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์—๋„ ์•ˆ์ „) * 4. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (์ „์ฒด ์ƒํ’ˆ) * 5. ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ (Cross-BC ์ „์šฉ โ€” ACL์—์„œ ํ˜ธ์ถœ) * 6. ํ™œ์„ฑ ์ƒํ’ˆ ์ผ๊ด„ ์กฐํšŒ (Cross-BC ์ „์šฉ โ€” ACL์—์„œ ํ˜ธ์ถœ) */ - // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ + // 1. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (์บ์‹œ ์ ์šฉ โ€” PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ, Read Model projection ๊ธฐ๋ฐ˜) @Transactional(readOnly = true) public ProductDetailOutDto getProduct(Long id) { - // ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ - Product product = productQueryService.findActiveById(id); - - // ๋ธŒ๋žœ๋“œ ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ์‘๋‹ต) - Brand brand = brandQueryService.getBrandById(product.getBrandId()); - - // DTO ๋ณ€ํ™˜ - return ProductDetailOutDto.from(product, brand.getName().value()); + // Read Model์—์„œ ProductCacheDto โ†’ ProductDetailOutDto ๋ณ€ํ™˜ (์บ์‹œ ์ ์šฉ) + return productQueryService.getOrLoadProductDetail(id); } @@ -58,18 +46,10 @@ public ProductPageOutDto getProducts(Long brandId, ProductSortType sortType, int } - // 3. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ + // 3. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (Read Model ๊ธฐ๋ฐ˜ โ€” ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์—๋„ ๋น„์ •๊ทœํ™”๋œ brandName ์‚ฌ์šฉ) @Transactional(readOnly = true) public AdminProductDetailOutDto getAdminProduct(Long id) { - - // ์ƒํ’ˆ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ) - Product product = productQueryService.findById(id); - - // ๋ธŒ๋žœ๋“œ ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ ์‘๋‹ต) - Brand brand = brandQueryService.getBrandById(product.getBrandId()); - - // DTO ๋ณ€ํ™˜ - return AdminProductDetailOutDto.from(product, brand.getName().value()); + return productQueryService.getAdminProductDetail(id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java index dd2fd8e1a..8a164cd1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java @@ -7,15 +7,23 @@ import com.loopers.catalog.product.domain.model.Product; import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.repository.ProductQueryRepository; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.loopers.catalog.product.infrastructure.cache.ProductCacheConstants.*; @Service @@ -24,8 +32,11 @@ public class ProductQueryService { // repository private final ProductQueryRepository productQueryRepository; + private final ProductReadModelRepository productReadModelRepository; // port private final ProductQueryPort productQueryPort; + // cache + private final ProductCacheManager productCacheManager; /** @@ -33,9 +44,12 @@ public class ProductQueryService { * 1. ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ * 2. ๋ธŒ๋žœ๋“œ์˜ ํ™œ์„ฑ ์ƒํ’ˆ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ * 3. ID๋กœ ์ƒํ’ˆ ์กฐํšŒ (์‚ญ์ œ ํฌํ•จ, ๊ด€๋ฆฌ์ž์šฉ) - * 4. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (QueryPort, ํŽ˜์ด์ง€๋„ค์ด์…˜) - * 5. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (QueryPort, ํŽ˜์ด์ง€๋„ค์ด์…˜) + * 4. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (2๊ณ„์ธต ์บ์‹œ: ID ๋ฆฌ์ŠคํŠธ โ†’ MGET ์ƒ์„ธ) + * 5. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (์บ์‹œ ๋ฏธ์ ์šฉ) * 6. ID ๋ชฉ๋ก์œผ๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์ผ๊ด„ ์กฐํšŒ (Cross-BC ์ „์šฉ) + * 7. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์กฐํšŒ (PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ) + * 8. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) + * 9. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (Read Model projection โ€” ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์—๋„ ์•ˆ์ „) */ // 1. ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ @@ -61,22 +75,48 @@ public Product findById(Long id) { } - // 4. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (QueryPort, ํŽ˜์ด์ง€๋„ค์ด์…˜) + // 4. ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (2๊ณ„์ธต ์บ์‹œ: ID ๋ฆฌ์ŠคํŠธ โ†’ MGET ์ƒ์„ธ) @Transactional(readOnly = true) public ProductPageOutDto searchProducts(Long brandId, ProductSortType sortType, int page, int size) { - // ๊ฒ€์ƒ‰ ์กฐ๊ฑด ์ƒ์„ฑ - ProductSearchCriteria criteria = new ProductSearchCriteria(brandId, sortType); - - // QueryPort๋ฅผ ํ†ตํ•œ ๊ฒ€์ƒ‰ - PageResult result = productQueryPort.searchProducts(criteria, new PageCriteria(page, size)); - - // DTO ๋ณ€ํ™˜ - return ProductPageOutDto.from(result); + // ์บ์‹œ ์ ์šฉ ์กฐ๊ฑด ํ™•์ธ (page <= MAX_CACHEABLE_PAGE && size == DEFAULT_PAGE_SIZE) + if (!isCacheable(page, size)) { + return searchFromDb(brandId, sortType, page, size); + } + + // Layer 1: ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ์กฐํšŒ (cache-aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ) + String idListKey = buildIdListCacheKey(brandId, sortType, page, size); + IdListCacheEntry idList = productCacheManager.getOrLoad( + idListKey, IdListCacheEntry.class, ID_LIST_TTL, + () -> loadIdListFromDb(brandId, sortType, page, size) + ); + + // ๋นˆ ๊ฒฐ๊ณผ์ธ ๊ฒฝ์šฐ ๋นˆ ํŽ˜์ด์ง€ ๋ฐ˜ํ™˜ + if (idList.ids().isEmpty()) { + return new ProductPageOutDto(List.of(), page, size, idList.totalElements()); + } + + // Layer 2: MGET ์ƒ์„ธ ์บ์‹œ ์กฐํšŒ + List cached = productCacheManager.mgetProductDetails(idList.ids()); + + // partial miss ์ฒ˜๋ฆฌ โ€” miss๋œ ID๋ฅผ DB์—์„œ ์กฐํšŒ ํ›„ ์บ์‹œ ์ €์žฅ + List missedIds = extractMissedIds(idList.ids(), cached); + if (!missedIds.isEmpty()) { + List fromDb = loadAndCacheDetails(missedIds); + cached = mergeInOrder(idList.ids(), cached, fromDb); + } + + // dangling ID ๋ฐฉ์–ด (์‚ญ์ œ๋˜์—ˆ์œผ๋‚˜ ID ๋ฆฌ์ŠคํŠธ์— ๋‚จ์€ ๊ฒฝ์šฐ null skip) + List content = cached.stream() + .filter(Objects::nonNull) + .map(ProductCacheDto::toProductOutDto) + .toList(); + + return new ProductPageOutDto(content, page, size, idList.totalElements()); } - // 5. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (QueryPort, ํŽ˜์ด์ง€๋„ค์ด์…˜) + // 5. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (์บ์‹œ ๋ฏธ์ ์šฉ โ€” ๊ด€๋ฆฌ์ž๋Š” ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ํ•„์š”) @Transactional(readOnly = true) public AdminProductPageOutDto searchAdminProducts(Long brandId, ProductSortType sortType, int page, int size) { @@ -97,4 +137,118 @@ public List findActiveByIds(List ids) { return productQueryRepository.findActiveByIds(ids); } + + // 7. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์กฐํšŒ (PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ, Read Model projection ๊ธฐ๋ฐ˜) + @Transactional(readOnly = true) + public ProductDetailOutDto getOrLoadProductDetail(Long productId) { + + // ์บ์‹œ ํ‚ค: product:v1:{productId} + String cacheKey = DETAIL_KEY_PREFIX + productId; + + // PER + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋กœ ์บ์‹œ ์กฐํšŒ/๋กœ๋“œ (ProductCacheDto ๊ธฐ๋ฐ˜) + ProductCacheDto cacheDto = productCacheManager.getOrLoadWithPer( + cacheKey, ProductCacheDto.class, DETAIL_TTL, + () -> productQueryPort.findProductCacheDtoById(productId) + ); + + // ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ์‚ญ์ œ๋œ ๊ฒฝ์šฐ (loader๊ฐ€ null ๋ฐ˜ํ™˜) + if (cacheDto == null) { + throw new CoreException(ErrorType.PRODUCT_NOT_FOUND); + } + + // ProductCacheDto โ†’ ProductDetailOutDto ๋ณ€ํ™˜ + return cacheDto.toProductDetailOutDto(); + } + + + // 8. ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) + @Transactional(readOnly = true) + public List findActiveIdsByBrandId(Long brandId) { + return productReadModelRepository.findActiveIdsByBrandId(brandId); + } + + + // 9. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ (Read Model projection โ€” ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์—๋„ ๋น„์ •๊ทœํ™”๋œ brandName ์‚ฌ์šฉ) + @Transactional(readOnly = true) + public AdminProductDetailOutDto getAdminProductDetail(Long productId) { + AdminProductDetailOutDto result = productQueryPort.findAdminProductDetailById(productId); + if (result == null) { + throw new CoreException(ErrorType.PRODUCT_NOT_FOUND); + } + return result; + } + + + /** + * private method + * - ์บ์‹œ ์ ์šฉ ์กฐ๊ฑด ํŒ์ • + * - DB ์ง์ ‘ ์กฐํšŒ (์บ์‹œ ๋ฏธ์ ์šฉ ๊ฒฝ๋กœ) + * - ID ๋ฆฌ์ŠคํŠธ DB ์กฐํšŒ (cache-aside loader) + * - MGET partial miss ์ฒ˜๋ฆฌ + * - ์บ์‹œ ํ‚ค ์ƒ์„ฑ + */ + + // ์บ์‹œ ์ ์šฉ ์กฐ๊ฑด ํŒ์ • + private boolean isCacheable(int page, int size) { + return page < MAX_CACHEABLE_PAGE && size == DEFAULT_PAGE_SIZE; + } + + + // DB ์ง์ ‘ ์กฐํšŒ (์บ์‹œ ๋ฏธ์ ์šฉ ๊ฒฝ๋กœ โ€” page 3 ์ด์ƒ ๋˜๋Š” size != DEFAULT_PAGE_SIZE) + private ProductPageOutDto searchFromDb(Long brandId, ProductSortType sortType, int page, int size) { + ProductSearchCriteria criteria = new ProductSearchCriteria(brandId, sortType); + PageResult result = productQueryPort.searchProducts(criteria, new PageCriteria(page, size)); + return ProductPageOutDto.from(result); + } + + + // ID ๋ฆฌ์ŠคํŠธ DB ์กฐํšŒ (cache-aside loader) + private IdListCacheEntry loadIdListFromDb(Long brandId, ProductSortType sortType, int page, int size) { + ProductSearchCriteria criteria = new ProductSearchCriteria(brandId, sortType); + return productQueryPort.searchProductIds(criteria, new PageCriteria(page, size)); + } + + + // MGET partial miss๋œ ID ์ถ”์ถœ + private List extractMissedIds(List ids, List cached) { + List missed = new ArrayList<>(); + for (int i = 0; i < ids.size(); i++) { + if (i >= cached.size() || cached.get(i) == null) { + missed.add(ids.get(i)); + } + } + return missed; + } + + + // miss๋œ ์ƒํ’ˆ์„ DB์—์„œ ์กฐํšŒ ํ›„ ๊ฐœ๋ณ„ ์บ์‹œ ์ €์žฅ + private List loadAndCacheDetails(List missedIds) { + List dtos = productQueryPort.findProductCacheDtosByIds(missedIds); + for (ProductCacheDto dto : dtos) { + productCacheManager.put(DETAIL_KEY_PREFIX + dto.id(), dto, DETAIL_TTL); + } + return dtos; + } + + + // ID ์ˆœ์„œ๋Œ€๋กœ cached์™€ fromDb๋ฅผ ๋ณ‘ํ•ฉ (ID ๋ฆฌ์ŠคํŠธ ์ˆœ์„œ ๋ณด์กด) + private List mergeInOrder(List ids, List cached, List fromDb) { + + // fromDb๋ฅผ id ๊ธฐ๋ฐ˜ Map์œผ๋กœ ๋ณ€ํ™˜ + Map dbMap = fromDb.stream() + .collect(Collectors.toMap(ProductCacheDto::id, Function.identity())); + + // ID ์ˆœ์„œ๋Œ€๋กœ ๋ณ‘ํ•ฉ โ€” cached์— ์žˆ์œผ๋ฉด cached ์‚ฌ์šฉ, ์—†์œผ๋ฉด dbMap์—์„œ ์กฐํšŒ + List merged = new ArrayList<>(ids.size()); + for (int i = 0; i < ids.size(); i++) { + ProductCacheDto c = (i < cached.size()) ? cached.get(i) : null; + if (c != null) { + merged.add(c); + } else { + merged.add(dbMap.get(ids.get(i))); + } + } + return merged; + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductCommandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductCommandFacadeTest.java index b79053ace..d9ce2ff88 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductCommandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductCommandFacadeTest.java @@ -10,6 +10,7 @@ import com.loopers.catalog.product.application.service.ProductCommandService; import com.loopers.catalog.product.application.service.ProductQueryService; import com.loopers.catalog.product.domain.model.Product; +import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.model.vo.Money; import com.loopers.catalog.product.domain.model.vo.ProductName; import com.loopers.catalog.product.domain.model.vo.Stock; @@ -57,7 +58,7 @@ private Product createTestProduct() { ProductName.from("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), Money.from(new BigDecimal("10000")), Stock.from(100L), - null, 0L, null); + null, null); } @@ -71,7 +72,7 @@ private Brand createTestBrand() { class CreateProductTest { @Test - @DisplayName("[createProduct()] ์œ ํšจํ•œ ์š”์ฒญ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ") + @DisplayName("[createProduct()] ์œ ํšจํ•œ ์š”์ฒญ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ. Read Model ๋™๊ธฐํ™” + write-through ์บ์‹œ ๊ฐฑ์‹ ") void createProductSuccess() { // Arrange AdminProductCreateInDto inDto = new AdminProductCreateInDto( @@ -80,8 +81,12 @@ void createProductSuccess() { Brand brand = createTestBrand(); Product product = createTestProduct(); + AdminProductDetailOutDto detailOutDto = new AdminProductDetailOutDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L, null); + given(brandQueryService.getBrandById(1L)).willReturn(brand); given(productCommandService.createProduct(inDto)).willReturn(product); + given(productQueryService.getAdminProductDetail(1L)).willReturn(detailOutDto); // Act AdminProductDetailOutDto result = productCommandFacade.createProduct(inDto); @@ -92,7 +97,11 @@ void createProductSuccess() { () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), () -> verify(brandQueryService).getBrandById(1L), - () -> verify(productCommandService).createProduct(inDto) + () -> verify(productCommandService).createProduct(inDto), + () -> verify(productCommandService).syncReadModel(product, "๋‚˜์ดํ‚ค"), + () -> verify(productCommandService).refreshProductDetailCache(1L), + () -> verify(productCommandService).refreshIdListCacheForAllSorts(1L), + () -> verify(productQueryService).getAdminProductDetail(1L) ); } @@ -104,7 +113,7 @@ void createProductSuccess() { class UpdateProductTest { @Test - @DisplayName("[updateProduct()] ์œ ํšจํ•œ ์ˆ˜์ • ์š”์ฒญ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜") + @DisplayName("[updateProduct()] ์œ ํšจํ•œ ์ˆ˜์ • ์š”์ฒญ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. Read Model ๋™๊ธฐํ™” + write-through ์บ์‹œ ๊ฐฑ์‹ ") void updateProductSuccess() { // Arrange AdminProductUpdateInDto inDto = new AdminProductUpdateInDto( @@ -115,12 +124,16 @@ void updateProductSuccess() { ProductName.from("์ˆ˜์ • ์ƒํ’ˆ"), Money.from(new BigDecimal("20000")), Stock.from(200L), - null, 0L, null); + null, null); Brand brand = createTestBrand(); + AdminProductDetailOutDto detailOutDto = new AdminProductDetailOutDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "์ˆ˜์ • ์ƒํ’ˆ", new BigDecimal("20000"), 200L, "์ˆ˜์ • ์„ค๋ช…", 0L, null); + given(productQueryService.findActiveById(1L)).willReturn(product); given(productCommandService.updateProduct(eq(product), eq(inDto))).willReturn(updatedProduct); given(brandQueryService.getBrandById(1L)).willReturn(brand); + given(productQueryService.getAdminProductDetail(1L)).willReturn(detailOutDto); // Act AdminProductDetailOutDto result = productCommandFacade.updateProduct(1L, inDto); @@ -129,7 +142,11 @@ void updateProductSuccess() { assertAll( () -> assertThat(result.name()).isEqualTo("์ˆ˜์ • ์ƒํ’ˆ"), () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), - () -> verify(productQueryService).findActiveById(1L) + () -> verify(productQueryService).findActiveById(1L), + () -> verify(productCommandService).syncReadModel(updatedProduct, "๋‚˜์ดํ‚ค"), + () -> verify(productCommandService).refreshProductDetailCache(1L), + () -> verify(productCommandService).refreshIdListCacheForSort(1L, ProductSortType.PRICE_ASC), + () -> verify(productQueryService).getAdminProductDetail(1L) ); } @@ -141,7 +158,7 @@ void updateProductSuccess() { class DeleteProductTest { @Test - @DisplayName("[deleteProduct()] ํ™œ์„ฑ ์ƒํ’ˆ ID -> ์‚ญ์ œ ๋ฐ ์ข‹์•„์š”/์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ฆฌ ์ˆ˜ํ–‰") + @DisplayName("[deleteProduct()] ํ™œ์„ฑ ์ƒํ’ˆ ID -> ์‚ญ์ œ ๋ฐ ์ข‹์•„์š”/์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ฆฌ ์ˆ˜ํ–‰. ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ + ID ๋ฆฌ์ŠคํŠธ write-through ๊ฐฑ์‹ ") void deleteProductSuccess() { // Arrange Product product = createTestProduct(); @@ -156,6 +173,8 @@ void deleteProductSuccess() { assertAll( () -> verify(productQueryService).findActiveById(1L), () -> verify(productCommandService).deleteProduct(product), + () -> verify(productCommandService).deleteProductDetailCache(1L), + () -> verify(productCommandService).refreshIdListCacheForAllSorts(1L), () -> verify(productCommandService).deleteAllProductLikes(1L), () -> verify(productCommandService).deleteAllCartItems(1L) ); diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductQueryFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductQueryFacadeTest.java index 090186236..873f5ef35 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductQueryFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/facade/ProductQueryFacadeTest.java @@ -1,9 +1,6 @@ package com.loopers.catalog.product.application.facade; -import com.loopers.catalog.brand.application.service.BrandQueryService; -import com.loopers.catalog.brand.domain.model.Brand; -import com.loopers.catalog.brand.domain.model.vo.BrandName; import com.loopers.catalog.product.application.dto.out.*; import com.loopers.catalog.product.application.service.ProductQueryService; import com.loopers.catalog.product.domain.model.Product; @@ -34,17 +31,13 @@ class ProductQueryFacadeTest { @Mock private ProductQueryService productQueryService; - @Mock - private BrandQueryService brandQueryService; private ProductQueryFacade productQueryFacade; @BeforeEach void setUp() { - productQueryFacade = new ProductQueryFacade( - productQueryService, brandQueryService - ); + productQueryFacade = new ProductQueryFacade(productQueryService); } @@ -53,12 +46,7 @@ private Product createTestProduct() { ProductName.from("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), Money.from(new BigDecimal("10000")), Stock.from(100L), - null, 0L, null); - } - - - private Brand createTestBrand() { - return Brand.reconstruct(1L, BrandName.from("๋‚˜์ดํ‚ค"), null, null, null); + null, null); } @@ -67,13 +55,12 @@ private Brand createTestBrand() { class GetProductTest { @Test - @DisplayName("[getProduct()] ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ -> ProductDetailOutDto ๋ฐ˜ํ™˜. ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ") + @DisplayName("[getProduct()] ํ™œ์„ฑ ์ƒํ’ˆ ์กฐํšŒ -> ProductQueryService.getOrLoadProductDetail()์— ์œ„์ž„. ProductDetailOutDto ๋ฐ˜ํ™˜") void getProductSuccess() { // Arrange - Product product = createTestProduct(); - Brand brand = createTestBrand(); - given(productQueryService.findActiveById(1L)).willReturn(product); - given(brandQueryService.getBrandById(1L)).willReturn(brand); + ProductDetailOutDto detailOutDto = new ProductDetailOutDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L); + given(productQueryService.getOrLoadProductDetail(1L)).willReturn(detailOutDto); // Act ProductDetailOutDto result = productQueryFacade.getProduct(1L); @@ -81,10 +68,13 @@ void getProductSuccess() { // Assert assertAll( () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.brandId()).isEqualTo(1L), () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), - () -> verify(productQueryService).findActiveById(1L), - () -> verify(brandQueryService).getBrandById(1L) + () -> assertThat(result.price()).isEqualByComparingTo(new BigDecimal("10000")), + () -> assertThat(result.stock()).isEqualTo(100L), + () -> assertThat(result.likeCount()).isEqualTo(0L), + () -> verify(productQueryService).getOrLoadProductDetail(1L) ); } @@ -122,13 +112,12 @@ void getProductsSuccess() { class GetAdminProductTest { @Test - @DisplayName("[getAdminProduct()] ๊ด€๋ฆฌ์ž ์ƒ์„ธ ์กฐํšŒ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. ๋ธŒ๋žœ๋“œ๋ช… ํฌํ•จ") + @DisplayName("[getAdminProduct()] ๊ด€๋ฆฌ์ž ์ƒ์„ธ ์กฐํšŒ -> ProductQueryService.getAdminProductDetail()์— ์œ„์ž„. AdminProductDetailOutDto ๋ฐ˜ํ™˜") void getAdminProductSuccess() { // Arrange - Product product = createTestProduct(); - Brand brand = createTestBrand(); - given(productQueryService.findById(1L)).willReturn(product); - given(brandQueryService.getBrandById(1L)).willReturn(brand); + AdminProductDetailOutDto detailOutDto = new AdminProductDetailOutDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L, null); + given(productQueryService.getAdminProductDetail(1L)).willReturn(detailOutDto); // Act AdminProductDetailOutDto result = productQueryFacade.getAdminProduct(1L); @@ -137,7 +126,8 @@ void getAdminProductSuccess() { assertAll( () -> assertThat(result.id()).isEqualTo(1L), () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), - () -> verify(productQueryService).findById(1L) + () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), + () -> verify(productQueryService).getAdminProductDetail(1L) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java index dbda98f13..f0fc520de 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java @@ -1,10 +1,7 @@ package com.loopers.catalog.product.application.service; -import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; -import com.loopers.catalog.product.application.dto.out.AdminProductPageOutDto; -import com.loopers.catalog.product.application.dto.out.ProductOutDto; -import com.loopers.catalog.product.application.dto.out.ProductPageOutDto; +import com.loopers.catalog.product.application.dto.out.*; import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.model.Product; @@ -13,8 +10,12 @@ import com.loopers.catalog.product.domain.model.vo.ProductName; import com.loopers.catalog.product.domain.model.vo.Stock; import com.loopers.catalog.product.domain.repository.ProductQueryRepository; +import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; +import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -26,14 +27,20 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; +import java.time.Duration; +import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -43,14 +50,20 @@ class ProductQueryServiceTest { @Mock private ProductQueryRepository productQueryRepository; @Mock + private ProductReadModelRepository productReadModelRepository; + @Mock private ProductQueryPort productQueryPort; + @Mock + private ProductCacheManager productCacheManager; private ProductQueryService productQueryService; @BeforeEach void setUp() { - productQueryService = new ProductQueryService(productQueryRepository, productQueryPort); + productQueryService = new ProductQueryService( + productQueryRepository, productReadModelRepository, productQueryPort, productCacheManager + ); } @@ -59,7 +72,7 @@ private Product createTestProduct() { ProductName.from("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), Money.from(new BigDecimal("10000")), Stock.from(100L), - null, 0L, null); + null, null); } @@ -180,23 +193,116 @@ void findByIdNotFound() { class SearchProductsTest { @Test - @DisplayName("[searchProducts()] ๊ฒ€์ƒ‰ ์กฐ๊ฑด์œผ๋กœ ์กฐํšŒ -> ProductPageOutDto ๋ฐ˜ํ™˜") - void searchProductsSuccess() { - // Arrange + @DisplayName("[searchProducts()] ์บ์‹œ ๋ถˆ๊ฐ€ ์กฐ๊ฑด (page >= MAX_CACHEABLE_PAGE) -> DB ์ง์ ‘ ์กฐํšŒ. ์บ์‹œ ๋ฏธ์‚ฌ์šฉ") + void searchProductsNotCacheable() { + // Arrange โ€” page=2 (MAX_CACHEABLE_PAGE=2 ์ด์ƒ) ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.LATEST); - PageCriteria pageCriteria = new PageCriteria(0, 20); + PageCriteria pageCriteria = new PageCriteria(2, 20); ProductOutDto outDto = new ProductOutDto(1L, 1L, null, "์ƒํ’ˆ", new BigDecimal("10000"), 100L, 0L); - PageResult pageResult = new PageResult<>(List.of(outDto), 0, 20, 1); + PageResult pageResult = new PageResult<>(List.of(outDto), 2, 20, 50); given(productQueryPort.searchProducts(criteria, pageCriteria)).willReturn(pageResult); // Act - ProductPageOutDto result = productQueryService.searchProducts(null, ProductSortType.LATEST, 0, 20); + ProductPageOutDto result = productQueryService.searchProducts(null, ProductSortType.LATEST, 2, 20); // Assert assertAll( () -> assertThat(result.content()).hasSize(1), - () -> assertThat(result.totalElements()).isEqualTo(1), - () -> verify(productQueryPort).searchProducts(criteria, pageCriteria) + () -> assertThat(result.page()).isEqualTo(2), + () -> verify(productQueryPort).searchProducts(criteria, pageCriteria), + () -> verifyNoInteractions(productCacheManager) + ); + } + + + @Test + @DisplayName("[searchProducts()] 2๊ณ„์ธต ์บ์‹œ ์ „์ฒด ํžˆํŠธ -> ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ + MGET ์ƒ์„ธ ์บ์‹œ ๋ชจ๋‘ ํžˆํŠธ. DB ๋ฏธํ˜ธ์ถœ") + @SuppressWarnings("unchecked") + void searchProductsFullCacheHit() { + // Arrange โ€” Layer 1: ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํžˆํŠธ + IdListCacheEntry idList = new IdListCacheEntry(List.of(1L, 2L), 2); + given(productCacheManager.getOrLoad( + eq("products:ids:v1:all:LATEST:0:20"), + eq(IdListCacheEntry.class), + any(Duration.class), + any(Supplier.class) + )).willReturn(idList); + + // Layer 2: MGET ์ƒ์„ธ ์บ์‹œ ์ „์ฒด ํžˆํŠธ + ProductCacheDto dto1 = new ProductCacheDto(1L, 1L, "๋‚˜์ดํ‚ค", "์ƒํ’ˆ1", new BigDecimal("10000"), 100L, null, 0L); + ProductCacheDto dto2 = new ProductCacheDto(2L, 1L, "๋‚˜์ดํ‚ค", "์ƒํ’ˆ2", new BigDecimal("20000"), 50L, null, 5L); + given(productCacheManager.mgetProductDetails(List.of(1L, 2L))).willReturn(List.of(dto1, dto2)); + + // Act + ProductPageOutDto result = productQueryService.searchProducts(null, ProductSortType.LATEST, 0, 20); + + // Assert + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.content().get(0).name()).isEqualTo("์ƒํ’ˆ1"), + () -> assertThat(result.content().get(1).name()).isEqualTo("์ƒํ’ˆ2"), + () -> assertThat(result.totalElements()).isEqualTo(2), + () -> verifyNoInteractions(productQueryPort) + ); + } + + + @Test + @DisplayName("[searchProducts()] MGET partial miss -> miss๋œ ID๋ฅผ DB์—์„œ ์กฐํšŒ ํ›„ ์บ์‹œ ์ €์žฅ. ID ์ˆœ์„œ ๋ณด์กด") + @SuppressWarnings("unchecked") + void searchProductsMgetPartialMiss() { + // Arrange โ€” Layer 1: ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํžˆํŠธ + IdListCacheEntry idList = new IdListCacheEntry(List.of(1L, 2L), 2); + given(productCacheManager.getOrLoad( + eq("products:ids:v1:1:LATEST:0:20"), + eq(IdListCacheEntry.class), + any(Duration.class), + any(Supplier.class) + )).willReturn(idList); + + // Layer 2: MGET โ€” id=1 ํžˆํŠธ, id=2 miss (null) + ProductCacheDto dto1 = new ProductCacheDto(1L, 1L, "๋‚˜์ดํ‚ค", "์ƒํ’ˆ1", new BigDecimal("10000"), 100L, null, 0L); + given(productCacheManager.mgetProductDetails(List.of(1L, 2L))).willReturn(Arrays.asList(dto1, null)); + + // miss๋œ id=2๋ฅผ DB์—์„œ ์กฐํšŒ + ProductCacheDto dto2 = new ProductCacheDto(2L, 1L, "๋‚˜์ดํ‚ค", "์ƒํ’ˆ2", new BigDecimal("20000"), 50L, null, 5L); + given(productQueryPort.findProductCacheDtosByIds(List.of(2L))).willReturn(List.of(dto2)); + + // Act + ProductPageOutDto result = productQueryService.searchProducts(1L, ProductSortType.LATEST, 0, 20); + + // Assert โ€” ID ์ˆœ์„œ ๋ณด์กด (1 โ†’ 2) + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.content().get(0).id()).isEqualTo(1L), + () -> assertThat(result.content().get(1).id()).isEqualTo(2L), + () -> verify(productQueryPort).findProductCacheDtosByIds(List.of(2L)), + () -> verify(productCacheManager).put(eq("product:v1:2"), eq(dto2), any(Duration.class)) + ); + } + + + @Test + @DisplayName("[searchProducts()] ๋นˆ ID ๋ฆฌ์ŠคํŠธ -> ๋นˆ ํŽ˜์ด์ง€ ๋ฐ˜ํ™˜. Layer 2 MGET ๋ฏธํ˜ธ์ถœ") + @SuppressWarnings("unchecked") + void searchProductsEmptyIdList() { + // Arrange โ€” Layer 1: ๋นˆ ID ๋ฆฌ์ŠคํŠธ + IdListCacheEntry idList = new IdListCacheEntry(List.of(), 0); + given(productCacheManager.getOrLoad( + eq("products:ids:v1:all:LATEST:0:20"), + eq(IdListCacheEntry.class), + any(Duration.class), + any(Supplier.class) + )).willReturn(idList); + + // Act + ProductPageOutDto result = productQueryService.searchProducts(null, ProductSortType.LATEST, 0, 20); + + // Assert + assertAll( + () -> assertThat(result.content()).isEmpty(), + () -> assertThat(result.totalElements()).isEqualTo(0), + () -> verify(productCacheManager).getOrLoad(any(), any(), any(), any()) ); } @@ -246,7 +352,7 @@ void findActiveByIdsEmpty() { class SearchAdminProductsTest { @Test - @DisplayName("[searchAdminProducts()] ๊ด€๋ฆฌ์ž ๊ฒ€์ƒ‰ ์กฐ๊ฑด์œผ๋กœ ์กฐํšŒ -> AdminProductPageOutDto ๋ฐ˜ํ™˜") + @DisplayName("[searchAdminProducts()] ๊ด€๋ฆฌ์ž ๊ฒ€์ƒ‰ -> ์บ์‹œ ๋ฏธ์ ์šฉ. ํ•ญ์ƒ QueryPort ํ˜ธ์ถœ. AdminProductPageOutDto ๋ฐ˜ํ™˜") void searchAdminProductsSuccess() { // Arrange ProductSearchCriteria criteria = new ProductSearchCriteria(null, ProductSortType.LATEST); @@ -263,7 +369,165 @@ void searchAdminProductsSuccess() { assertAll( () -> assertThat(result.content()).hasSize(1), () -> assertThat(result.totalElements()).isEqualTo(1), - () -> verify(productQueryPort).searchAdminProducts(criteria, pageCriteria) + () -> verify(productQueryPort).searchAdminProducts(criteria, pageCriteria), + () -> verifyNoInteractions(productCacheManager) + ); + } + + } + + + @Nested + @DisplayName("getOrLoadProductDetail()") + class GetOrLoadProductDetailTest { + + @Test + @DisplayName("[getOrLoadProductDetail()] ์บ์‹œ ํžˆํŠธ -> ProductCacheDto์—์„œ ProductDetailOutDto๋กœ ๋ณ€ํ™˜ ๋ฐ˜ํ™˜. QueryPort ๋ฏธํ˜ธ์ถœ") + void cacheHit() { + // Arrange + ProductCacheDto cacheDto = new ProductCacheDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L); + given(productCacheManager.getOrLoadWithPer( + eq("product:v1:1"), + eq(ProductCacheDto.class), + any(Duration.class), + any(Supplier.class) + )).willReturn(cacheDto); + + // Act + ProductDetailOutDto result = productQueryService.getOrLoadProductDetail(1L); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), + () -> verifyNoInteractions(productQueryPort) + ); + } + + + @Test + @DisplayName("[getOrLoadProductDetail()] ์ƒํ’ˆ ๋ฏธ์กด์žฌ (loader๊ฐ€ null ๋ฐ˜ํ™˜) -> PRODUCT_NOT_FOUND ์˜ˆ์™ธ") + @SuppressWarnings("unchecked") + void productNotFound() { + // Arrange โ€” loader๊ฐ€ null ๋ฐ˜ํ™˜ (์ƒํ’ˆ ๋ฏธ์กด์žฌ ๋˜๋Š” ์‚ญ์ œ) + given(productCacheManager.getOrLoadWithPer( + eq("product:v1:999"), + eq(ProductCacheDto.class), + any(Duration.class), + any(Supplier.class) + )).willReturn(null); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productQueryService.getOrLoadProductDetail(999L)); + + // Assert + assertAll( + () -> assertThat(exception.getErrorType()).isEqualTo(ErrorType.PRODUCT_NOT_FOUND), + () -> assertThat(exception.getMessage()).isEqualTo(ErrorType.PRODUCT_NOT_FOUND.getMessage()) + ); + } + + + @Test + @DisplayName("[getOrLoadProductDetail()] ์บ์‹œ ๋ฏธ์Šค -> QueryPort.findProductCacheDtoById() ํ˜ธ์ถœ ํ›„ ์บ์‹œ ์ €์žฅ. ProductDetailOutDto ๋ฐ˜ํ™˜") + @SuppressWarnings("unchecked") + void cacheMiss() { + // Arrange + ProductCacheDto cacheDto = new ProductCacheDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L); + given(productQueryPort.findProductCacheDtoById(1L)).willReturn(cacheDto); + + // loader ์‹คํ–‰์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ (์บ์‹œ ๋ฏธ์Šค ์‹œ supplier ํ˜ธ์ถœ) + given(productCacheManager.getOrLoadWithPer( + eq("product:v1:1"), + eq(ProductCacheDto.class), + any(Duration.class), + any(Supplier.class) + )).willAnswer(invocation -> { + Supplier loader = invocation.getArgument(3); + return loader.get(); + }); + + // Act + ProductDetailOutDto result = productQueryService.getOrLoadProductDetail(1L); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), + () -> verify(productQueryPort).findProductCacheDtoById(1L) + ); + } + + } + + + @Nested + @DisplayName("getAdminProductDetail()") + class GetAdminProductDetailTest { + + @Test + @DisplayName("[getAdminProductDetail()] Read Model์— ์ƒํ’ˆ ์กด์žฌ -> AdminProductDetailOutDto ๋ฐ˜ํ™˜. ์‚ญ์ œ๋œ ๋ธŒ๋žœ๋“œ์—๋„ ์•ˆ์ „") + void getAdminProductDetailSuccess() { + // Arrange + AdminProductDetailOutDto detailOutDto = new AdminProductDetailOutDto( + 1L, 1L, "๋‚˜์ดํ‚ค", "ํ…Œ์ŠคํŠธ ์ƒํ’ˆ", new BigDecimal("10000"), 100L, null, 0L, null); + given(productQueryPort.findAdminProductDetailById(1L)).willReturn(detailOutDto); + + // Act + AdminProductDetailOutDto result = productQueryService.getAdminProductDetail(1L); + + // Assert + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.brandName()).isEqualTo("๋‚˜์ดํ‚ค"), + () -> assertThat(result.name()).isEqualTo("ํ…Œ์ŠคํŠธ ์ƒํ’ˆ"), + () -> verify(productQueryPort).findAdminProductDetailById(1L) + ); + } + + + @Test + @DisplayName("[getAdminProductDetail()] Read Model์— ์ƒํ’ˆ ๋ฏธ์กด์žฌ -> PRODUCT_NOT_FOUND ์˜ˆ์™ธ") + void getAdminProductDetailNotFound() { + // Arrange + given(productQueryPort.findAdminProductDetailById(999L)).willReturn(null); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productQueryService.getAdminProductDetail(999L)); + + // Assert + assertAll( + () -> assertThat(exception.getErrorType()).isEqualTo(ErrorType.PRODUCT_NOT_FOUND), + () -> assertThat(exception.getMessage()).isEqualTo(ErrorType.PRODUCT_NOT_FOUND.getMessage()) + ); + } + + } + + + @Nested + @DisplayName("findActiveIdsByBrandId()") + class FindActiveIdsByBrandIdTest { + + @Test + @DisplayName("[findActiveIdsByBrandId()] ๋ธŒ๋žœ๋“œ ID -> ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ๋ฐ˜ํ™˜. ReadModelRepository์— ์œ„์ž„") + void findActiveIdsByBrandIdSuccess() { + // Arrange + given(productReadModelRepository.findActiveIdsByBrandId(1L)).willReturn(List.of(10L, 20L, 30L)); + + // Act + List result = productQueryService.findActiveIdsByBrandId(1L); + + // Assert + assertAll( + () -> assertThat(result).containsExactly(10L, 20L, 30L), + () -> verify(productReadModelRepository).findActiveIdsByBrandId(1L) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/interfaces/ProductControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/interfaces/ProductControllerE2ETest.java index 077341ca6..b3277a0b4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/interfaces/ProductControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/interfaces/ProductControllerE2ETest.java @@ -11,6 +11,7 @@ import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -48,6 +49,9 @@ class ProductControllerE2ETest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private RedisCleanUp redisCleanUp; + private static final String ADMIN_LDAP_HEADER = "X-Loopers-Ldap"; private static final String ADMIN_LDAP_VALUE = "loopers.admin"; @@ -55,6 +59,7 @@ class ProductControllerE2ETest { @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @@ -731,6 +736,37 @@ void updateProductSoftDeleted() throws Exception { .andExpect(jsonPath("$.code").value(ErrorType.PRODUCT_NOT_FOUND.getCode())); } + + @Test + @DisplayName("[PUT + GET /api/v1/products/{productId}] ์ƒํ’ˆ ์ˆ˜์ • ํ›„ ์ƒ์„ธ ์กฐํšŒ -> ์บ์‹œ ๋ฌดํšจํ™”๋˜์–ด ์ˆ˜์ •๋œ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜") + void updateProductThenGetReturnsUpdatedData() throws Exception { + // Arrange + Long brandId = createBrandAndGetId("๋‚˜์ดํ‚ค", "์Šคํฌ์ธ  ๋ธŒ๋žœ๋“œ"); + Long productId = createProductAndGetId(brandId, "์—์–ด๋งฅ์Šค", new BigDecimal("129000"), 100L, "๋Ÿฌ๋‹ํ™”"); + + // ์ƒ์„ธ ์กฐํšŒ (์บ์‹œ์— ์ €์žฅ๋จ) + mockMvc.perform(get("/api/v1/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("์—์–ด๋งฅ์Šค")); + + // ์ƒํ’ˆ ์ˆ˜์ • (์บ์‹œ ๋ฌดํšจํ™” ๋ฐœ์ƒ) + AdminProductUpdateRequest updateRequest = new AdminProductUpdateRequest( + "์—์–ด๋งฅ์Šค 97", new BigDecimal("159000"), 200L, "๋ ˆํŠธ๋กœ ๋Ÿฌ๋‹ํ™”"); + mockMvc.perform(put("/api-admin/v1/products/{productId}", productId) + .header(ADMIN_LDAP_HEADER, ADMIN_LDAP_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()); + + // Act & Assert โ€” ์ˆ˜์ •๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜ํ™˜๋˜์–ด์•ผ ํ•จ (์บ์‹œ ๋ฌดํšจํ™” ๊ฒ€์ฆ) + mockMvc.perform(get("/api/v1/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("์—์–ด๋งฅ์Šค 97")) + .andExpect(jsonPath("$.price").value(159000)) + .andExpect(jsonPath("$.stock").value(200)) + .andExpect(jsonPath("$.description").value("๋ ˆํŠธ๋กœ ๋Ÿฌ๋‹ํ™”")); + } + } diff --git a/docs/todo/cache-event-driven-refresh.md b/docs/todo/cache-event-driven-refresh.md new file mode 100644 index 000000000..d35f36198 --- /dev/null +++ b/docs/todo/cache-event-driven-refresh.md @@ -0,0 +1,85 @@ +# TODO: ์บ์‹œ ๊ฐฑ์‹  ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์ „ํ™˜ + +## ๊ฐœ์š” + +ํ˜„์žฌ ์บ์‹œ write-through๋Š” `@Transactional` ๋‚ด๋ถ€์—์„œ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค. +TX ๋กค๋ฐฑ ์‹œ ์บ์‹œ์— ์ž˜๋ชป๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋‚จ์„ ์ˆ˜ ์žˆ์œผ๋ฉฐ, TTL ์•ˆ์ „๋ง(2~3๋ถ„)์— ์˜์กดํ•œ๋‹ค. + +์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ํ•˜๋ฉด TX ์ปค๋ฐ‹ ํ™•์ • ํ›„์—๋งŒ ์บ์‹œ๋ฅผ ๊ฐฑ์‹ ํ•˜์—ฌ ์ •ํ•ฉ์„ฑ์„ ๋ณด์žฅํ•  ์ˆ˜ ์žˆ๋‹ค. + +## ํ˜„์žฌ ์ƒํƒœ (Round 5) + +```java +// ProductCommandService โ€” TX ๋‚ด๋ถ€์—์„œ ์บ์‹œ ์ง์ ‘ ๊ฐฑ์‹  +@Transactional +public void increaseLikeCount(Long productId) { + readModelRepository.increaseLikeCount(productId); + // ์บ์‹œ write-through (TX ๋‚ด๋ถ€ โ€” ๋กค๋ฐฑ ์‹œ ์บ์‹œ ๋ถˆ์ผ์น˜ ๊ฐ€๋Šฅ) + productCacheManager.refreshProductDetail(productId, () -> productQueryPort.findProductCacheDtoById(productId)); +} +``` + +## ๋ชฉํ‘œ ์ƒํƒœ + +```java +// ProductCommandService โ€” ์ด๋ฒคํŠธ ๋ฐœํ–‰๋งŒ +@Transactional +public void increaseLikeCount(Long productId) { + readModelRepository.increaseLikeCount(productId); + // ์ด๋ฒคํŠธ ๋ฐœํ–‰ (TX ๋‚ด๋ถ€์—์„œ๋Š” ์บ์‹œ ๋ฏธ์ ‘์ด‰) + eventPublisher.publishEvent(new ProductCacheRefreshEvent(productId, RefreshType.LIKE_COUNT)); +} + +// ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ โ€” TX ์ปค๋ฐ‹ ํ›„ ์‹คํ–‰ +@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) +public void onProductCacheRefresh(ProductCacheRefreshEvent event) { + productCacheManager.refreshProductDetail(event.productId()); + productCacheManager.refreshIdLists(event.productId(), event.refreshType()); +} +``` + +## ๋ณ€ํ™˜ ๋Œ€์ƒ ๋ฉ”์„œ๋“œ + +| ๋ฉ”์„œ๋“œ | ํ˜„์žฌ | ๋ชฉํ‘œ | +|--------|------|------| +| `ProductCommandService.increaseLikeCount()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | +| `ProductCommandService.decreaseLikeCount()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | +| `ProductCommandService.decreaseStock()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | +| `ProductCommandService.createProduct()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | +| `ProductCommandService.updateProduct()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | +| `ProductCommandService.deleteProduct()` | TX ๋‚ด ์บ์‹œ ๊ฐฑ์‹  | ์ด๋ฒคํŠธ ๋ฐœํ–‰ | + +## ์‹ ๊ทœ ์ƒ์„ฑ ํŒŒ์ผ + +| ํŒŒ์ผ | ์œ„์น˜ | ์—ญํ•  | +|------|------|------| +| `ProductCacheRefreshEvent` | `catalog/product/domain/event/` | ์บ์‹œ ๊ฐฑ์‹  ์ด๋ฒคํŠธ (productId, refreshType) | +| `ProductCacheRefreshListener` | `catalog/product/interfaces/event/` | `@TransactionalEventListener` ๋ฆฌ์Šค๋„ˆ | + +## ์ฐธ๊ณ : CLAUDE.md ์ด๋ฒคํŠธ ๊ทœ์น™ + +- Event ํด๋ž˜์Šค Javadoc์— `@subscriber` ๋ชฉ๋ก ๋ช…์‹œ +- Publisher ์ชฝ ์ฃผ์„์— `โ†’ [{Listener}] {ํšจ๊ณผ}` ์ธ๋ผ์ธ ์ฃผ์„ ๊ธฐ๋ก + +## ์ฐธ๊ณ : ApplicationReadyEvent ๊ธฐ๋ฐ˜ ์บ์‹œ ์›œ์—… + +์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ hot ํŽ˜์ด์ง€๋ฅผ ์„ ์ œ์ ์œผ๋กœ ์บ์‹œ ์ ์žฌํ•˜๋Š” ๊ธฐ๋Šฅ๋„ ํ•จ๊ป˜ ๋„์ž…ํ•œ๋‹ค. + +```java +@EventListener(ApplicationReadyEvent.class) +public void warmUpCache() { + // ๋ชจ๋“  ํ•„ํ„ฐ ์กฐํ•ฉ ร— 3์ •๋ ฌ ร— pages 0~2 ์บ์‹œ ์„ ์ ์žฌ + // ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ๋„ hot ์ƒํ’ˆ ๊ธฐ์ค€์œผ๋กœ ์„ ์ ์žฌ +} +``` + +## ์šฐ์„ ์ˆœ์œ„ + +- ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์บ์‹œ ๊ฐฑ์‹ : **์ค‘๊ฐ„** (ํ˜„์žฌ TTL ์•ˆ์ „๋ง์œผ๋กœ ๋™์ž‘ ์ค‘, TX ๋กค๋ฐฑ์€ ๊ทนํžˆ ๋“œ๋ฌพ) +- ApplicationReadyEvent ์›œ์—…: **๋‚ฎ์Œ** (cache-aside๋กœ ์ดˆ๊ธฐ ์ ์žฌ ๊ฐ€๋Šฅ) + +## ๊ด€๋ จ ๋ฌธ์„œ + +- `round5-docs/08-cache-eviction-analysis.md` โ€” ์บ์‹œ ์ „๋žต ๋ถ„์„ ๋ฐ ์ตœ์ข… ์„ค๊ณ„ +- `docs/design/05-concurrency-strategy.md` โ€” ๋™์‹œ์„ฑ ์ „๋žต +- `round4-docs/03-sync-vs-event-analysis.md` โ€” ์ด๋ฒคํŠธ vs ๋™๊ธฐ ๋ถ„์„ diff --git a/round5-docs/05-to-be-cache-measurement.md b/round5-docs/05-to-be-cache-measurement.md new file mode 100644 index 000000000..2a820e65f --- /dev/null +++ b/round5-docs/05-to-be-cache-measurement.md @@ -0,0 +1,197 @@ +# TO-BE ์บ์‹œ ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ + +> ์‹ค์ธก ์žฌํ˜„ ๋ช…๋ น์–ด: `./gradlew :apps:commerce-api:benchmarkTest --tests '*ProductApiPerformanceTest.measureApiCache_*'` + +## ์ธก์ • ํ™˜๊ฒฝ + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| DB | MySQL 8.0 (TestContainers) | +| Cache | Redis (TestContainers) | +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | 10๋งŒ / 100๋งŒ / 1000๋งŒ | +| ๋ธŒ๋žœ๋“œ | 50๊ฐœ ๊ท ๋“ฑ ๋ถ„ํฌ | +| ์ธ๋ฑ์Šค | `product_read_model` ๋ณตํ•ฉ ์ธ๋ฑ์Šค 12๊ฐœ | +| ์บ์‹œ ๊ตฌ์กฐ | 2-Layer Cache (ID ๋ฆฌ์ŠคํŠธ + ์ƒ์„ธ) | +| ์ธก์ • ๋ ˆ๋ฒจ | MockMvc ๊ธฐ๋ฐ˜ API ์ „์ฒด ์Šคํƒ | + +## ํ˜„์žฌ ๊ตฌํ˜„ ๊ธฐ์ค€ ์š”์•ฝ + +### ์บ์‹œ ํ‚ค์™€ TTL + +| ๋Œ€์ƒ | ์บ์‹œ ํ‚ค ํŒจํ„ด | TTL | ๋น„๊ณ  | +|------|-------------|-----|------| +| ์ƒํ’ˆ ์ƒ์„ธ | `product:v1:{productId}` | 2๋ถ„ + jitter | PDP, PLP partial miss fallback ๊ณต์šฉ | +| ID ๋ฆฌ์ŠคํŠธ | `products:ids:v1:{brandId\|all}:{sort}:{page}:{size}` | 3๋ถ„ + jitter | ๋ชฉ๋ก ์ •๋ ฌ/ํ•„ํ„ฐ ์กฐํ•ฉ๋ณ„ ID ์บ์‹œ | + +### ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + +| ๊ธฐ๋ฒ• | ํ˜„์žฌ ๊ตฌํ˜„ | +|------|----------| +| TTL Jitter | ์ ์šฉ | +| PER | ์ ์šฉ | +| Cache Lock | `LocalCacheLock` + double-check | + +### Write-Through ๋ฒ”์œ„ + +| ๋ณ€๊ฒฝ ์ž‘์—… | ์ƒ์„ธ ์บ์‹œ | ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ | +|----------|----------|---------------| +| ์ข‹์•„์š” ์ฆ๊ฐ€/๊ฐ์†Œ | ๊ฐฑ์‹  | ๊ฐฑ์‹  ์•ˆ ํ•จ (TTL ์ž์—ฐ ๋งŒ๋ฃŒ ํ—ˆ์šฉ) | +| ์žฌ๊ณ  ์ฐจ๊ฐ | ๊ฐฑ์‹  | ๊ฐฑ์‹  ์•ˆ ํ•จ | +| ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ | ๊ฐฑ์‹  | `PRICE_ASC` ๋ชฉ๋ก ๊ฐฑ์‹  | +| ์ƒํ’ˆ ์ƒ์„ฑ/์‚ญ์ œ | ์ƒ์„ฑ/์‚ญ์ œ | ์˜ํ–ฅ ๋ชฉ๋ก ๊ฐฑ์‹  | +| ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ | ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์ƒ์„ธ ์ผ๊ด„ ๊ฐฑ์‹  | ๊ฐฑ์‹  ์•ˆ ํ•จ | + +### ์ธก์ • ๋ฐฉ์‹ + +- `MISS`: warmup๋„ ๋งค๋ฒˆ Redis๋ฅผ ๋น„์šด ๋’ค ์ˆ˜ํ–‰ํ•˜๊ณ , ์ธก์ • ์ง์ „์—๋„ Redis๋ฅผ ๋น„์šด ๋’ค 1ํšŒ ์š”์ฒญํ•œ๋‹ค. +- `HIT`: ๋ช…์‹œ์ ์œผ๋กœ ์บ์‹œ๋ฅผ ํ•œ ๋ฒˆ ์ฑ„์šด ๋’ค warmup 3ํšŒ, ์ธก์ • 5ํšŒ๋ฅผ ์ˆ˜ํ–‰ํ•œ๋‹ค. +- ๋‹จ์ผ/๋ฒ„์ŠคํŠธ/์ง€์†๋ถ€ํ•˜ ๋ชจ๋‘ `2xx` ์‘๋‹ต๋งŒ ์„ฑ๊ณต ์ƒ˜ํ”Œ๋กœ ์ง‘๊ณ„ํ•œ๋‹ค. +- ์บ์‹œ ๊ฐฑ์‹ ์€ ํ˜„์žฌ ๊ตฌํ˜„์ฒ˜๋Ÿผ ํŠธ๋žœ์žญ์…˜ ๋‚ด๋ถ€ best-effort write-through ๊ธฐ์ค€์œผ๋กœ ์ธก์ •ํ–ˆ๋‹ค. +- `afterCommit` ์ด๋ฒคํŠธ ์ „ํ™˜์€ ์ด๋ฒˆ ๋ผ์šด๋“œ ๋ฒ”์œ„ ๋ฐ– TODO๋‹ค. + +--- + +## ๋‹จ์ผ API ์š”์ฒญ + +### Cache Miss + +#### ๋ชฉ๋ก API (`GET /api/v1/products`) + +| UC | ์กฐ๊ฑด | ์ •๋ ฌ | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | +|----|------|------|:---:|:---:|:---:| +| 1 | brandId=X | LATEST | **39.85** | **142.18** | **1166.02** | +| 2 | brandId=X | PRICE_ASC | **54.95** | **334.30** | **3884.57** | +| 3 | brandId=X | LIKES_DESC | **32.85** | **122.44** | **1160.68** | +| 4 | brandId=1 | LATEST | **25.41** | **22.51** | **51.69** | +| 5 | brandId=1 | PRICE_ASC | **21.50** | **26.85** | **97.49** | +| 6 | brandId=1 | LIKES_DESC | **24.95** | **20.77** | **44.58** | + +#### ์ƒ์„ธ API (`GET /api/v1/products/{id}`) + +| UC | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | +|----|:---:|:---:|:---:| +| ์ƒ์„ธ: productId=1 | **9.50** | **6.41** | **6.32** | + +- cold-cache ๊ธฐ์ค€์—์„œ๋Š” `brandId` ์—†๋Š” ๋ชฉ๋ก์ด ์—ฌ์ „ํžˆ DB ์ฟผ๋ฆฌ ๋น„์šฉ์˜ ์˜ํ–ฅ์„ ํฌ๊ฒŒ ๋ฐ›๋Š”๋‹ค. +- ํŠนํžˆ `PRICE_ASC`๋Š” 1000๋งŒ๊ฑด์—์„œ **3.88s**๊นŒ์ง€ ์ƒ์Šนํ–ˆ๋‹ค. ์บ์‹œ๋ฅผ โ€œ์—†์• ๋„ ๋˜๋Š” ์ˆ˜์ค€โ€์ด๋ผ๊ณ  ๋ณด๊ธฐ ์–ด๋ ค์šด ์ด์œ ๋‹ค. +- ์ƒ์„ธ miss๋Š” PK lookup ๊ธฐ๋ฐ˜์ด๋ผ ์ „ ๊ทœ๋ชจ์—์„œ **6~10ms**๋กœ ์•ˆ์ •์ ์ด๋‹ค. + +### Cache Hit + +#### ๋ชฉ๋ก API (`GET /api/v1/products`) + +| UC | ์กฐ๊ฑด | ์ •๋ ฌ | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | +|----|------|------|:---:|:---:|:---:| +| 1 | brandId=X | LATEST | **5.99** | **6.10** | **5.15** | +| 2 | brandId=X | PRICE_ASC | **7.65** | **7.39** | **6.42** | +| 3 | brandId=X | LIKES_DESC | **7.41** | **5.67** | **5.17** | +| 4 | brandId=1 | LATEST | **7.17** | **6.45** | **4.85** | +| 5 | brandId=1 | PRICE_ASC | **6.23** | **5.21** | **4.98** | +| 6 | brandId=1 | LIKES_DESC | **5.76** | **4.66** | **4.53** | + +#### ์ƒ์„ธ API (`GET /api/v1/products/{id}`) + +| UC | 10๋งŒ avg (ms) | 100๋งŒ avg (ms) | 1000๋งŒ avg (ms) | +|----|:---:|:---:|:---:| +| ์ƒ์„ธ: productId=1 | **4.98** | **4.77** | **3.85** | + +- hot-cache ๊ฒฝ๋กœ๋Š” ์ „ ๊ทœ๋ชจ์—์„œ **3.85~7.65ms** ๋ฒ”์œ„๋กœ ์ˆ˜๋ ดํ–ˆ๋‹ค. +- ์ฆ‰, ํ˜„์žฌ ์บ์‹œ์˜ ์ง„์งœ ๊ฐ€์น˜ ๋Š” โ€œcold-cache๋ฅผ ๋น ๋ฅด๊ฒŒ ๋งŒ๋“œ๋Š” ๊ฒƒโ€๋ณด๋‹ค โ€œhot-path๋ฅผ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์™€ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒโ€์— ์žˆ๋‹ค. + +--- + +## ๋ฒ„์ŠคํŠธ ์ธก์ • (100 concurrent) + +### Cache Hit + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ | avg (ms) | p95 (ms) | +|:---:|------|:---:|:---:|:---:|:---:| +| 10๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **121.32** | **183.20** | +| 10๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 100/100 | 0 | **183.83** | **247.96** | +| 10๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 100/100 | 0 | **100.70** | **145.90** | +| 10๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **51.90** | **101.51** | +| 100๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **215.73** | **258.76** | +| 100๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 100/100 | 0 | **213.48** | **255.92** | +| 100๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 100/100 | 0 | **93.50** | **133.44** | +| 100๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **77.10** | **111.47** | +| 1000๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **1245.17** | **1287.48** | +| 1000๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 100/100 | 0 | **1287.17** | **1331.43** | +| 1000๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 100/100 | 0 | **158.50** | **200.02** | +| 1000๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **48.26** | **72.73** | + +### Cache Miss + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ | avg (ms) | p95 (ms) | +|:---:|------|:---:|:---:|:---:|:---:| +| 10๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **225.83** | **269.18** | +| 10๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **108.51** | **170.80** | +| 100๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **216.68** | **258.23** | +| 100๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **62.30** | **93.85** | +| 1000๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 100/100 | 0 | **1145.78** | **1172.37** | +| 1000๋งŒ | ์ƒ์„ธ: productId=1 | 100/100 | 0 | **58.49** | **85.97** | + +- same-key miss burst๋Š” ๋ชจ๋‘ **์—๋Ÿฌ 0%**๋กœ ์ข…๋ฃŒ๋๋‹ค. +- `LocalCacheLock` + double-check ๋•๋ถ„์— loader ์ค‘๋ณต ์‹คํ–‰์€ 1ํšŒ๋กœ ์ˆ˜๋ ดํ•˜๊ณ , ๋‚˜๋จธ์ง€ ์š”์ฒญ์€ lock ํ•ด์ œ ํ›„ ์บ์‹œ๋ฅผ ์žฌ์‚ฌ์šฉํ•œ๋‹ค. +- ๋‹ค๋งŒ 1000๋งŒ๊ฑด ์ „์ฒด ๋ชฉ๋ก์€ cache hit ๋ฒ„์ŠคํŠธ์กฐ์ฐจ **1.2์ดˆ๋Œ€**๊นŒ์ง€ ์˜ฌ๋ผ๊ฐ„๋‹ค. ์ด๊ฑด Redis๊ฐ€ ๋А๋ฆฐ ๊ฒŒ ์•„๋‹ˆ๋ผ, API ๋ ˆ์ด์–ด ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”์™€ 100 concurrent MockMvc ํ™˜๊ฒฝ ๋น„์šฉ์ด ๋ˆ„์ ๋œ ๊ฒฐ๊ณผ๋‹ค. + +--- + +## ์ง€์† ๋ถ€ํ•˜ ์ธก์ • (20 RPS ร— 10์ดˆ, Cache Hit) + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | UC | ์™„๋ฃŒ/์ „์ฒด | ์—๋Ÿฌ | ์‹ค์ œ QPS | avg (ms) | p95 (ms) | +|:---:|------|:---:|:---:|:---:|:---:|:---:| +| 10๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | **8.34** | **14.42** | +| 10๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | **9.13** | **9.66** | +| 10๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | **7.35** | **9.78** | +| 10๋งŒ | ์ƒ์„ธ: productId=1 | 200/200 | 0 | **20.0** | **6.16** | **8.36** | +| 100๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | **6.67** | **8.18** | +| 100๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | **8.34** | **10.47** | +| 100๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | **7.17** | **11.14** | +| 100๋งŒ | ์ƒ์„ธ: productId=1 | 200/200 | 0 | **20.0** | **6.07** | **8.92** | +| 1000๋งŒ | ๋ชฉ๋ก UC1: brandId=X, LATEST | 200/200 | 0 | **20.0** | **6.62** | **8.96** | +| 1000๋งŒ | ๋ชฉ๋ก UC3: brandId=X, LIKES_DESC | 200/200 | 0 | **20.0** | **6.22** | **7.44** | +| 1000๋งŒ | ๋ชฉ๋ก UC4: brandId=1, LATEST | 200/200 | 0 | **20.0** | **6.42** | **8.13** | +| 1000๋งŒ | ์ƒ์„ธ: productId=1 | 200/200 | 0 | **20.0** | **5.91** | **7.40** | + +- hot-cache sustained load์—์„œ๋Š” ์ „ ๊ทœ๋ชจ ๋ชจ๋‘ **20.0 QPS / ์—๋Ÿฌ 0%**๋ฅผ ์œ ์ง€ํ–ˆ๋‹ค. +- ์ฆ‰, ์šด์˜์ƒ ์ค‘์š”ํ•œ steady-state๋Š” ํ˜„์žฌ ์บ์‹œ ๊ตฌ์กฐ๋กœ ์ถฉ๋ถ„ํžˆ ๋ฐฉ์–ด๋œ๋‹ค. + +--- + +## ํ•ต์‹ฌ ๊ด€์ฐฐ + +### 1. hot-cache๋Š” ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์™€ ๊ฑฐ์˜ ๋ฌด๊ด€ํ•˜๋‹ค + +- ๋‹จ์ผ ์š”์ฒญ ๊ธฐ์ค€ `3.85~7.65ms` +- ์ง€์† ๋ถ€ํ•˜ ๊ธฐ์ค€ `5.91~9.13ms` +- 10๋งŒ, 100๋งŒ, 1000๋งŒ ๋ชจ๋‘ steady-state์—์„œ๋Š” ๊ฑฐ์˜ ๊ฐ™์€ ์‘๋‹ต ๊ตฌ๊ฐ„์— ๋“ค์–ด์˜จ๋‹ค. + +### 2. cold-cache๋Š” ์—ฌ์ „ํžˆ DB ๋น„์šฉ์— ์ข…์†๋œ๋‹ค + +- `brandId` ์—†๋Š” `PRICE_ASC`๋Š” 10๋งŒ `54.95ms` โ†’ 100๋งŒ `334.30ms` โ†’ 1000๋งŒ `3884.57ms` +- ์ฆ‰, โ€œ์ธ๋ฑ์Šค๊ฐ€ ์žˆ์œผ๋‹ˆ ์บ์‹œ ์—†์ด๋„ ์ถฉ๋ถ„ํ•˜๋‹คโ€๋Š” ๊ฒฐ๋ก ์€ ์„ฑ๋ฆฝํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### 3. ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋Š” same-key miss์—์„œ ์˜๋ฏธ ์žˆ๊ฒŒ ๋™์ž‘ํ•œ๋‹ค + +- miss burst ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค์—์„œ ์—๋Ÿฌ์œจ์€ ๋ชจ๋‘ `0%` +- loader ์ค‘๋ณต ์‹คํ–‰์€ `LocalCacheLock`๊ณผ `double-check`๋กœ ์ œ์–ด๋œ๋‹ค. + +### 4. ์ด๋ฒˆ ์ˆ˜์น˜๋Š” ์ด์ „ ๋ฌธ์„œ๋ณด๋‹ค MISS๊ฐ€ ๋” ๋†’๋‹ค + +- ์ด์œ ๋Š” ๊ธฐ์กด ๋ฌธ์„œ๊ฐ€ warmup ๊ณผ์ •์—์„œ ์ด๋ฏธ ์บ์‹œ๋ฅผ ์ฑ„์šด ๋’ค `MISS`๋ฅผ ์ธก์ •ํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. +- ์ด๋ฒˆ ์žฌ์ธก์ •์€ warmup๊ณผ ์ธก์ • ๋ชจ๋‘ Redis๋ฅผ ๋น„์›Œ์„œ **์‹ค์ œ cold-cache miss**๋งŒ ์ง‘๊ณ„ํ–ˆ๋‹ค. + +--- + +## ์š”์•ฝ + +| ์ง€ํ‘œ | ํ˜„์žฌ ๊ฒฐ๋ก  | +|------|----------| +| ๋‹จ์ผ ์š”์ฒญ Hit | ์ „ ๊ทœ๋ชจ `3.85~7.65ms` | +| ๋‹จ์ผ ์š”์ฒญ Miss | ์ฟผ๋ฆฌ ์กฐ๊ฑด์— ๋”ฐ๋ผ `6.32ms ~ 3884.57ms` | +| ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ | hit/miss ๋ชจ๋‘ `0%` | +| ์ง€์† ๋ถ€ํ•˜ | ์ „ ๊ทœ๋ชจ `20.0 QPS`, ์—๋Ÿฌ `0%` | +| ์„ค๊ณ„ ํ•ต์‹ฌ ๊ฐ€์น˜ | steady-state๋ฅผ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ์™€ ๋ถ„๋ฆฌ | +| ๋‚จ์€ TODO | `afterCommit` ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์บ์‹œ ๊ฐฑ์‹  ์ „ํ™˜ | + +> ์ƒ์„ธ ๋น„๊ต๋Š” [`03-as-is-performance-measurement.md`](./03-as-is-performance-measurement.md), [`04-to-be-index-measurement.md`](./04-to-be-index-measurement.md)์™€ ํ•จ๊ป˜ ์ฝ๋Š”๋‹ค. diff --git a/round5-docs/05-to-be-cache-visualization.html b/round5-docs/05-to-be-cache-visualization.html new file mode 100644 index 000000000..13d4570c8 --- /dev/null +++ b/round5-docs/05-to-be-cache-visualization.html @@ -0,0 +1,691 @@ + + + + + + TO-BE ์บ์‹œ ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ ์‹œ๊ฐํ™” + + + + + + + +

TO-BE ์บ์‹œ ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ

+

+ Redis Cache-Aside + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ (PER + LocalCacheLock + TTL Jitter) · + ์ธ๋ฑ์Šค ์ตœ์ ํ™” Read Model · TestContainers MySQL 8.0 + Redis +

+ +
+ 10๋งŒ๊ฑด / 100๋งŒ๊ฑด / 1000๋งŒ๊ฑด: ์ „์ฒด ์‹ค์ธก ๋ฐ์ดํ„ฐ
+ ์‹ค์ธก ์žฌํ˜„ ๋ช…๋ น์–ด: ./gradlew :apps:commerce-api:benchmarkTest --tests '*ProductApiPerformanceTest.measureApiCache_*' +
+ + + + +
+
+
+
Cache Hit ์‘๋‹ต์‹œ๊ฐ„ (1000๋งŒ๊ฑด ์‹ค์ธก)
+
3.85~6.42ms
+
hot-cache ๋‹จ์ผ ์š”์ฒญ, ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ์˜ํ–ฅ ๋ฏธ๋ฏธ
+
+
+
Cold Cache Miss (1000๋งŒ๊ฑด ์‹ค์ธก)
+
44.58ms~3.88s
+
๋Œ€ํ‘œ ๋ชฉ๋ก cold-cache, ์ƒ์„ธ miss๋Š” 6.32ms
+
+
+
1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ
+
0%
+
AS-IS ๋ชฉ๋ก: 90% (1000๋งŒ๊ฑด ์‹ค์ธก)
+
+
+
1000๋งŒ๊ฑด ์ง€์†๋ถ€ํ•˜ QPS
+
20.0
+
avg 5.91~6.62ms, ์—๋Ÿฌ 0%
+
+
+
Redis ์žฅ์•  ๊ฒฉ๋ฆฌ
+
DB fallback
+
try-catch, ๊ฐ€์šฉ์„ฑ 100%
+
+
+
์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ
+
PER + Lock
+
Jitter + PER + LocalCacheLock
+
+
+ + + + +

1. Cache Hit vs Cache Miss ์‘๋‹ต์‹œ๊ฐ„

+

+ Cache Hit: Redis GET(ID ๋ฆฌ์ŠคํŠธ) + MGET(์ƒ์„ธ) + JSON ์—ญ์ง๋ ฌํ™” (3.85~6.42ms, 1000๋งŒ๊ฑด ์‹ค์ธก).
+ Cache Miss: warmup ์˜ค์—ผ ์—†์ด Redis๋ฅผ ๋น„์šด ์‹ค์ œ cold-cache ์ธก์ •. ๋Œ€ํ‘œ ๋ชฉ๋ก worst-case๋Š” 3.88s๊นŒ์ง€ ์ƒ์Šน.
+ ์™ผ์ชฝ: ์ „์ฒด ๋ชฉ๋ก ๋Œ€ํ‘œ UC, ์˜ค๋ฅธ์ชฝ: ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ƒ์„ธ. +

+ +
+
+

์ „์ฒด ๋ชฉ๋ก โ€” Cache Hit vs Cache Miss hot-cache 5~7ms

+
+
+
+

๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ƒ์„ธ โ€” Cache Hit vs Cache Miss

+
+
+
+ + + + +

2. AS-IS vs TO-BE ๋น„๊ต (Cache Hit) ๋กœ๊ทธ ์Šค์ผ€์ผ

+

+ AS-IS๋Š” `03-as-is-performance-measurement.md`์˜ Full Scan API ์‹ค์ธก, TO-BE๋Š” ํ˜„์žฌ cache hit ์‹ค์ธก๊ฐ’์ด๋‹ค.
+ 1000๋งŒ๊ฑด ๋ชฉ๋ก API ๊ธฐ์ค€์œผ๋กœ hot-cache๋Š” ์—ฌ์ „ํžˆ 4.5~6.4ms์ด๋ฉฐ, AS-IS ๋Œ€๋น„ ์ตœ๋Œ€ 2,561๋ฐฐ ๊ฐœ์„ ๋œ๋‹ค. +

+ +
+
+

๋ชฉ๋ก API: AS-IS vs TO-BE Cache Hit ์ตœ๋Œ€ 2,561๋ฐฐ ๊ฐœ์„  (1000๋งŒ๊ฑด)

+
+
+
+

์ƒ์„ธ API: AS-IS vs TO-BE Cache Hit

+
+
+
+ +
+
+

๊ฐœ์„  ๋ฐฐ์œจ (AS-IS / TO-BE Cache Hit) โ€” ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๋ณ„

+
+
+
+ + + + +

3. AS-IS vs TO-BE ๋น„๊ต (Cache Miss)

+

+ Cache Miss๋Š” ์ด๋ฒˆ์— warmup ์˜ค์—ผ์„ ์ œ๊ฑฐํ•œ ์‹ค์ œ cold-cache ์ˆ˜์น˜๋‹ค.
+ ๋Œ€ํ‘œ UC ๊ธฐ์ค€: 1000๋งŒ๊ฑด ์ „์ฒด+LATEST๋Š” 6,174ms โ†’ 1,166ms, ๋ธŒ๋žœ๋“œ+LATEST๋Š” 6,643ms โ†’ 51.69ms๋กœ ๊ฐœ์„ ๋œ๋‹ค. +

+ +
+
+

๋ชฉ๋ก API: AS-IS vs TO-BE Cache Miss

+
+
+
+

์ƒ์„ธ API: AS-IS vs TO-BE Cache Miss

+
+
+
+ + + + +

4. ์—๋Ÿฌ์œจ ๋น„๊ต (AS-IS vs TO-BE)

+

+ AS-IS๋Š” 100๋งŒ๊ฑด๋ถ€ํ„ฐ ์ปค๋„ฅ์…˜ ํƒ€์ž„์•„์›ƒ์œผ๋กœ ์‹คํŒจ๊ฐ€ ์‹œ์ž‘๋˜๊ณ , 1000๋งŒ๊ฑด ๋ชฉ๋ก์€ 90~95% ์‹คํŒจํ•œ๋‹ค.
+ TO-BE๋Š” ์บ์‹œ๊ฐ€ DB ๋ถ€ํ•˜๋ฅผ ํก์ˆ˜ํ•˜์—ฌ hit/miss ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค ๋ชจ๋‘ ์—๋Ÿฌ 0%.
+ ์™ผ์ชฝ: ๋ฒ„์ŠคํŠธ (100 concurrent), ์˜ค๋ฅธ์ชฝ: ์ง€์† ๋ถ€ํ•˜ (20 RPS x 10์ดˆ). +

+ +
+
+

๋ฒ„์ŠคํŠธ ์—๋Ÿฌ์œจ: AS-IS vs TO-BE (100 concurrent) TO-BE: ์ „๊ทœ๋ชจ 0%

+
+
+
+

์ง€์† ๋ถ€ํ•˜ ์—๋Ÿฌ์œจ: AS-IS vs TO-BE (20 RPS x 10์ดˆ)

+
+
+
+ +
+

์—๋Ÿฌ์œจ ํ•ต์‹ฌ ๊ฐœ์„ 

+
    +
  • 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ: AS-IS ๋ชฉ๋ก 90% ์‹คํŒจ โ†’ TO-BE 0%
  • +
  • 1000๋งŒ๊ฑด ์ง€์† ๋ถ€ํ•˜: AS-IS ๋ชฉ๋ก 90~95% ์‹คํŒจ, QPS 0.3~0.5 โ†’ TO-BE 0%, QPS 20.0
  • +
  • Cache Miss ์‹œ์—๋„ 0%: same-key miss๋Š” LocalCacheLock + double-check๋กœ ๋‹จ์ผ ๋กœ๋“œ๋กœ ์ˆ˜๋ ด
  • +
+
+ + + + +

5. QPS ๋น„๊ต (์ง€์† ๋ถ€ํ•˜)

+

+ ๋ชฉํ‘œ: 20 RPS. ๋นจ๊ฐ„ ์ ์„ ์ด ๋ชฉํ‘œ์„ .
+ AS-IS๋Š” 100๋งŒ๊ฑด๋ถ€ํ„ฐ ๋ชฉํ‘œ ๋ฏธ๋‹ฌ, 1000๋งŒ๊ฑด์—์„œ๋Š” 0.3~0.5 QPS๊นŒ์ง€ ํ•˜๋ฝ. TO-BE๋Š” ๋ชจ๋“  ๊ทœ๋ชจ์—์„œ 20 RPS ๋‹ฌ์„ฑ. +

+ +
+
+

AS-IS ๋ชฉ๋ก QPS (20 RPS x 10์ดˆ)

+
+
+
+

TO-BE QPS (20 RPS x 10์ดˆ) ์ „๊ทœ๋ชจ 20.0 ๋‹ฌ์„ฑ

+
+
+
+ + + + +

6. ์บ์‹œ ์•„ํ‚คํ…์ฒ˜

+

+ 2-Layer Cache-Aside + targeted write-through + Redis ์žฅ์•  ๊ฒฉ๋ฆฌ. +

+ +
+
+

2-Layer Cache + ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ

+
// Cache Hit ๊ฒฝ๋กœ (95%+ ํŠธ๋ž˜ํ”ฝ) +Client --> Controller --> Facade --> Redis GET --> [HIT] --> JSON Deserialize --> Response (~4-7ms) + +// Cache Miss ๊ฒฝ๋กœ (5%- ํŠธ๋ž˜ํ”ฝ) +Client --> Controller --> Facade --> Redis GET --> [MISS] + --> LocalCacheLock (double-check: ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ๊ฐ€ ์ด๋ฏธ ๋กœ๋“œํ–ˆ๋Š”์ง€ ํ™•์ธ) + --> Service --> Repository --> DB (Index Scan) + --> Redis SET (TTL + Jitter) + --> Response (cold-cache, ์กฐ๊ฑด๋ณ„ 6ms~3.88s) + +// Redis ์žฅ์•  ๊ฒฝ๋กœ (fallback) +Client --> Controller --> Facade --> Redis GET --> [ERROR] + --> try-catch: log & continue + --> Service --> Repository --> DB (Index Scan) + --> Response (DB fallback) (๊ฐ€์šฉ์„ฑ 100% ์œ ์ง€) + +// PER (Probabilistic Early Refresh) - TTL ์ž”์—ฌ 20% ๊ตฌ๊ฐ„ +Client --> Facade --> Redis GET --> [HIT, TTL ์ž”์—ฌ < 20%] + --> ํ™•๋ฅ ์  ํŒ๋‹จ: ๊ฐฑ์‹  ํ•„์š”? + --> [YES] ๋น„๋™๊ธฐ DB ์กฐํšŒ + Redis SET (๊ธฐ์กด ์บ์‹œ ์œ ์ง€, stale ํ—ˆ์šฉ) + --> [NO] ๊ธฐ์กด ์บ์‹œ ๋ฐ˜ํ™˜ + --> Response (~4-7ms) (์‚ฌ์šฉ์ž๋Š” ํ•ญ์ƒ ์ฆ‰์‹œ ์‘๋‹ต) + +// ์บ์‹œ ๋ฌดํšจํ™” +์ƒํ’ˆ ๋ณ€๊ฒฝ --> Targeted Write-Through + --> ์ƒ์„ธ: PUT product:v1:{id} (์‚ญ์ œ๋งŒ DEL) + --> ๋ชฉ๋ก: PUT products:ids:v1:{brandId|all}:{sort}:{page}:{size} (์˜ํ–ฅ ํ‚ค๋งŒ ๊ฐฑ์‹ ) + --> ์ข‹์•„์š”: ์ƒ์„ธ๋งŒ ๊ฐฑ์‹ , ID ๋ฆฌ์ŠคํŠธ๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ ํ—ˆ์šฉ
+
+
+ +
+
+

์บ์‹œ ํ‚ค ์„ค๊ณ„

+ + + + + + + + + + + + + + + + + + +
๋Œ€์ƒํ‚ค ํŒจํ„ดTTL๋ฌดํšจํ™”
์ƒํ’ˆ ์ƒ์„ธproduct:v1:{id}2๋ถ„ + jitter์ˆ˜์ •/์ข‹์•„์š”/์žฌ๊ณ /๋ธŒ๋žœ๋“œ๋ช… write-through, ์‚ญ์ œ๋งŒ DEL
ID ๋ฆฌ์ŠคํŠธproducts:ids:v1:{brandId|all}:{sort}:{page}:{size}3๋ถ„ + jitter์ƒ์„ฑ/์‚ญ์ œ targeted refresh, ๊ฐ€๊ฒฉ์€ PRICE_ASC๋งŒ, ์ข‹์•„์š”๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ
+
+
+

์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ 3๊ณ„์ธต

+ + + + + + + + + + + + + + + + + + + + + +
๊ณ„์ธต๊ธฐ๋ฒ•์—ญํ• 
1TTL Jitter (0~10%)๋™์‹œ ๋งŒ๋ฃŒ ๋ฐฉ์ง€ (์‹œ๊ฐ„ ๋ถ„์‚ฐ)
2PER (Early Refresh)๋งŒ๋ฃŒ ์ „ ์„ ์ œ ๊ฐฑ์‹  (spike ์ œ๊ฑฐ)
3LocalCacheLockDB ๋™์‹œ ์ ‘๊ทผ 1๊ฐœ๋กœ ์ œํ•œ
+
+
+ + + + +

๊ฒฐ๋ก 

+ +
+

์บ์‹œ ์ ์šฉ ํšจ๊ณผ ์š”์•ฝ (1000๋งŒ๊ฑด ์‹ค์ธก ๊ธฐ์ค€)

+
    +
  • Cache Hit ์‘๋‹ต์‹œ๊ฐ„ 3.85~6.42ms: 10๋งŒ~1000๋งŒ ์ „ ๊ตฌ๊ฐ„์—์„œ hot-cache ๋‹จ์ผ ์š”์ฒญ์€ ๊ฑฐ์˜ ๋™์ผํ•œ ์ˆ˜์ค€์ด๋‹ค.
  • +
  • 1000๋งŒ๊ฑด ๋ชฉ๋ก API: Cache Hit 4.53~6.42ms, Cache Miss๋Š” ์กฐ๊ฑด์— ๋”ฐ๋ผ 44.58ms~3,884.57ms๋‹ค.
  • +
  • 1000๋งŒ๊ฑด ๋ฒ„์ŠคํŠธ: hit/miss ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค ๋ชจ๋‘ ์—๋Ÿฌ์œจ 0%, ๋‹ค๋งŒ ์ „์ฒด ๋ชฉ๋ก hit avg๋Š” 1.2์ดˆ๋Œ€๊นŒ์ง€ ์ƒ์Šนํ•œ๋‹ค.
  • +
  • 1000๋งŒ๊ฑด ์ง€์† ๋ถ€ํ•˜: avg 5.91~6.62ms, ์—๋Ÿฌ์œจ 0%, QPS 20.0 ๋‹ฌ์„ฑ (20 RPS x 10์ดˆ, ์‹ค์ธก)
  • +
  • DB ๋ถ€ํ•˜ 95% ๊ฐ์†Œ: ์บ์‹œ ์ ์ค‘๋ฅ  95% ๋‹ฌ์„ฑ ์‹œ, DB๋Š” ์ „์ฒด ํŠธ๋ž˜ํ”ฝ์˜ 5%๋งŒ ์ฒ˜๋ฆฌ
  • +
  • Redis ์žฅ์•  ์‹œ์—๋„ ์„œ๋น„์Šค ์œ ์ง€: try-catch fallback + ์ธ๋ฑ์Šค ์ตœ์ ํ™” DB ์กฐํšŒ
  • +
+
+ +
+

์šด์˜ ์‹œ ๊ณ ๋ ค์‚ฌํ•ญ

+
    +
  • ์บ์‹œ ์ ์ค‘๋ฅ  ๋ชจ๋‹ˆํ„ฐ๋ง: ์ ์ค‘๋ฅ  90% ๋ฏธ๋งŒ ์‹œ TTL/ํ‚ค ์ „๋žต ์žฌ๊ฒ€ํ†  ํ•„์š”
  • +
  • cold-cache worst case: brandId ์—†๋Š” `PRICE_ASC`๋Š” 1000๋งŒ๊ฑด์—์„œ 3.88์ดˆ๊นŒ์ง€ ์ƒ์Šนํ•˜๋ฏ€๋กœ steady-state ์ ์ค‘๋ฅ  ๊ด€๋ฆฌ๊ฐ€ ํ•ต์‹ฌ
  • +
  • ๊ฐฑ์‹  ํƒ€์ด๋ฐ: write-through๋Š” ํ˜„์žฌ ํŠธ๋žœ์žญ์…˜ ๋‚ด๋ถ€ best-effort์ด๋ฉฐ, `afterCommit` ์ด๋ฒคํŠธ ์ „ํ™˜์€ ํ›„์† TODO
  • +
  • ์ผ๊ด€์„ฑ ์œˆ๋„์šฐ: ์ข‹์•„์š” ๋ณ€๊ฒฝ ์‹œ ID ๋ฆฌ์ŠคํŠธ๋Š” ์ฆ‰์‹œ ๊ฐฑ์‹ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์ตœ๋Œ€ 3๋ถ„ stale์ด ๊ฐ€๋Šฅ
  • +
  • Redis ๋ฉ”๋ชจ๋ฆฌ: maxmemory ์„ค์ • + eviction policy (allkeys-lru) ์ ์šฉ ๊ถŒ์žฅ
  • +
+
+ +

+ 10๋งŒ๊ฑด / 100๋งŒ๊ฑด / 1000๋งŒ๊ฑด: ์ „์ฒด ์‹ค์ธก ๋ฐ์ดํ„ฐ · + TestContainers MySQL 8.0 + Redis · + ์ƒ๋Œ€์  ๋น„๊ต(AS-IS vs TO-BE, Cache Hit vs Miss)๊ฐ€ ํ•ต์‹ฌ ์ง€ํ‘œ +

+ + + + + + + + diff --git a/round5-docs/06-2layer-cache-implementation-design.md b/round5-docs/06-2layer-cache-implementation-design.md new file mode 100644 index 000000000..edb284419 --- /dev/null +++ b/round5-docs/06-2layer-cache-implementation-design.md @@ -0,0 +1,527 @@ +# ์บ์‹œ 2๊ณ„์ธต ์•„ํ‚คํ…์ฒ˜ ๊ตฌํ˜„ ์„ค๊ณ„ + +> **๋ฌธ์„œ ์ƒํƒœ** +> - ์„ฑ๊ฒฉ: ํ˜„์žฌ ๊ตฌํ˜„์— ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ์„ค๊ณ„ ๋ฌธ์„œ +> - ์ „ํ™˜๊ธฐ TODO ํ•ญ๋ชฉ ๋ชจ๋‘ ์™„๋ฃŒ (`evictByPattern()` ์ œ๊ฑฐ, old key ์ •๋ฆฌ) +> - ํ˜„์žฌ ๊ตฌํ˜„ ํŒ๋‹จ์€ ์‹ค์ œ ์ฝ”๋“œ ์šฐ์„  + +## 1. ํ˜„์žฌ ์ƒํƒœ โ†’ ๋ชฉํ‘œ ์ƒํƒœ + +| ํ•ญ๋ชฉ | ํ˜„์žฌ (AS-IS) | ๋ชฉํ‘œ (TO-BE) | +|------|-------------|-------------| +| ๋ชฉ๋ก ์บ์‹œ ํ‚ค | `products:list:{brand\|all}:{sort}:{page}:{size}` | `products:ids:v1:{brand\|all}:{sort}:{page}:{size}` | +| ๋ชฉ๋ก ์บ์‹œ ๊ฐ’ | `ProductPageOutDto` (์ „์ฒด DTO) | `IdListCacheEntry` (ids + totalElements) | +| ์ƒ์„ธ ์บ์‹œ ํ‚ค | `product:{productId}` | `product:v1:{productId}` | +| ์ƒ์„ธ ์บ์‹œ ๊ฐ’ | `ProductDetailOutDto` | `ProductCacheDto` (PLP+PDP ๊ณต์šฉ) | +| TTL (๋ชฉ๋ก) | 5๋ถ„ | 3๋ถ„ | +| TTL (์ƒ์„ธ) | 10๋ถ„ | 2๋ถ„ | +| ์“ฐ๊ธฐ ์‹œ ๋™์ž‘ | `evictByPattern("products:list:*")` + `evict("product:"+id)` | write-through (targeted refresh) | +| PLP ์กฐํšŒ ํ๋ฆ„ | ์บ์‹œ์—์„œ ์ „์ฒด DTO ๋ฐ˜ํ™˜ | ID ๋ฆฌ์ŠคํŠธ โ†’ MGET โ†’ partial miss fill | +| ์บ์‹œ ์ ์šฉ ์กฐ๊ฑด | ๋ฌด์ œํ•œ | `page < MAX_CACHEABLE_PAGE && size == DEFAULT_PAGE_SIZE` | +| ์ •๋ ฌ ๋ณด์กฐ ํ‚ค | ์—†์Œ | `id` (tie-breaker) | + +--- + +## 2. ์‹ ๊ทœ/๋ณ€๊ฒฝ ํŒŒ์ผ ๋ชฉ๋ก + +### 2.1 ์‹ ๊ทœ ์ƒ์„ฑ + +| ํŒŒ์ผ | ์œ„์น˜ | ์—ญํ•  | +|------|------|------| +| `ProductCacheConstants` | `infrastructure/cache/` | ์บ์‹œ ํ‚ค ์ ‘๋‘์‚ฌ, ๋ฒ„์ „ ์ƒ์ˆ˜, DEFAULT_PAGE_SIZE | +| `ProductCacheDto` | `infrastructure/cache/` | PLP+PDP ๊ณต์šฉ ์บ์‹œ DTO (์ƒ์„ธ ์บ์‹œ ๊ฐ’) | +| `IdListCacheEntry` | `infrastructure/cache/` | ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ๊ฐ’ record (ids, totalElements) | + +### 2.2 ๋ณ€๊ฒฝ ๋Œ€์ƒ + +| ํŒŒ์ผ | ๋ณ€๊ฒฝ ๋‚ด์šฉ | +|------|----------| +| `ProductCacheManager` | write-through ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€, MGET ์ถ”๊ฐ€ | +| `ProductQueryService` | 2๊ณ„์ธต ์กฐํšŒ ํ๋ฆ„ (ID ๋ฆฌ์ŠคํŠธ โ†’ MGET), cacheable guard, TTL ๋ณ€๊ฒฝ, `findActiveIdsByBrandId()` ์ถ”๊ฐ€ | +| `ProductCommandService` | evict โ†’ write-through ํ˜ธ์ถœ๋กœ ์ „ํ™˜ | +| `ProductCommandFacade` | write-through ํ˜ธ์ถœ ์œ„์น˜ ์กฐ์ • (read model ๋™๊ธฐํ™” ์ดํ›„) | +| `BrandCommandFacade` | ๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • ์‹œ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through ์ถ”๊ฐ€ | +| `ProductQuerydslRepository` | tie-breaker ์ถ”๊ฐ€, `searchProductIds()` ์ถ”๊ฐ€, `findProductCacheDtosByIds()` bulk projection ์ถ”๊ฐ€ | +| `ProductReadModelJpaRepository` | `findActiveIdsByBrandId()` ์ถ”๊ฐ€ | +| `ProductReadModelRepository` | `findActiveIdsByBrandId()` ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€ | +| `ProductReadModelRepositoryImpl` | `findActiveIdsByBrandId()` ๊ตฌํ˜„ ์ถ”๊ฐ€ | + +--- + +## 3. ์ƒ์„ธ ์„ค๊ณ„ + +### 3.1 ProductCacheConstants + +```java +public final class ProductCacheConstants { + public static final String CACHE_VERSION = "v1"; + public static final String DETAIL_KEY_PREFIX = "product:" + CACHE_VERSION + ":"; + public static final String ID_LIST_KEY_PREFIX = "products:ids:" + CACHE_VERSION + ":"; + public static final int DEFAULT_PAGE_SIZE = 20; + public static final int MAX_CACHEABLE_PAGE = 2; + public static final Duration ID_LIST_TTL = Duration.ofMinutes(3); + public static final Duration DETAIL_TTL = Duration.ofMinutes(2); +} +``` + +### 3.2 ProductCacheDto + +PLP์™€ PDP์—์„œ ๊ณต์šฉ์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์บ์‹œ DTO. Read Model์—์„œ ์ง์ ‘ projection. + +```java +public record ProductCacheDto( + Long id, Long brandId, String brandName, String name, + BigDecimal price, Long stock, String description, Long likeCount +) { + // PLP ์‘๋‹ต์šฉ ๋ณ€ํ™˜ + public ProductOutDto toProductOutDto() { ... } + // PDP ์‘๋‹ต์šฉ ๋ณ€ํ™˜ + public ProductDetailOutDto toProductDetailOutDto() { ... } +} +``` + +### 3.3 IdListCacheEntry + +```java +public record IdListCacheEntry(List ids, long totalElements) {} +``` + +### 3.4 ProductCacheManager ์‹ ๊ทœ ๋ฉ”์„œ๋“œ + +CacheManager๋Š” Redis ์ „์šฉ ์œ ํ‹ธ๋กœ์„œ์˜ ์ฑ…์ž„๋งŒ ์œ ์ง€. DB ์กฐํšŒ๋Š” Supplier๋กœ ์œ„์ž„. + +```java +// 1. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through +public void refreshProductDetail(Long productId, Supplier loader) { + ProductCacheDto dto = loader.get(); + put(DETAIL_KEY_PREFIX + productId, dto, DETAIL_TTL); +} + +// 2. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ write-through (๋‹จ๊ฑด, Supplier ๊ธฐ๋ฐ˜) +public void refreshIdList(String cacheKey, Supplier loader) { + IdListCacheEntry entry = loader.get(); + put(cacheKey, entry, ID_LIST_TTL); +} + +// 3. ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ์‚ญ์ œ (์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์˜ˆ์™ธ์  ์‚ฌ์šฉ) +public void deleteProductDetail(Long productId) { + evict(DETAIL_KEY_PREFIX + productId); +} + +// 4. MGET (์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ƒ์„ธ ์ผ๊ด„ ์กฐํšŒ) +public List mgetProductDetails(List productIds) { + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + // RedisTemplate multiGet โ†’ ์—ญ์ง๋ ฌํ™” โ†’ null ํฌํ•จ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ +} +``` + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: `refreshIdLists(productId, brandId, RefreshType)` ๋ฉ”์„œ๋“œ ์ œ๊ฑฐ. +> CacheManager๊ฐ€ DB ์กฐํšŒ์™€ ํŠธ๋ฆฌ๊ฑฐ ๋งคํ•‘ ๋กœ์ง์„ ๊ฐ–๋Š” ๊ฒƒ์€ ์ฑ…์ž„ ๋ถˆ์ผ์น˜. +> ID list write-through ํ˜ธ์ถœ์€ Service/Facade์—์„œ ์ง์ ‘ ์ˆ˜ํ–‰ํ•˜๋˜, +> ์ข‹์•„์š”/์žฌ๊ณ  ๊ฐ™์€ ๊ณ ๋นˆ๋„ ๊ฒฝ๋กœ์—์„œ๋Š” ID list write-through๋ฅผ ํ•˜์ง€ ์•Š๋Š”๋‹ค (์•„๋ž˜ 3.6 ์ฐธ์กฐ). + +### 3.5 ProductQueryService 2๊ณ„์ธต ์กฐํšŒ ํ๋ฆ„ + +```java +// ์‚ฌ์šฉ์ž ์ƒํ’ˆ ๋ชฉ๋ก ๊ฒ€์ƒ‰ (2๊ณ„์ธต) +@Transactional(readOnly = true) +public ProductPageOutDto searchProducts(Long brandId, ProductSortType sortType, int page, int size) { + + // ์บ์‹œ ์ ์šฉ ์กฐ๊ฑด ํ™•์ธ + if (!isCacheable(page, size)) { + // ์บ์‹œ ๋ฏธ์ ์šฉ โ€” DB ์ง์ ‘ ์กฐํšŒ + return searchFromDb(brandId, sortType, page, size); + } + + // 1. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ์กฐํšŒ (cache-aside) + String idListKey = buildIdListCacheKey(brandId, sortType, page, size); + IdListCacheEntry idList = productCacheManager.getOrLoad( + idListKey, IdListCacheEntry.class, ID_LIST_TTL, + () -> loadIdListFromDb(brandId, sortType, page, size) + ); + + // 2. MGET ์ƒ์„ธ ์บ์‹œ + List cached = productCacheManager.mgetProductDetails(idList.ids()); + + // 3. partial miss ์ฒ˜๋ฆฌ + List missedIds = extractMissedIds(idList.ids(), cached); + if (!missedIds.isEmpty()) { + List fromDb = loadAndCacheDetails(missedIds); + cached = mergeInOrder(idList.ids(), cached, fromDb); + } + + // 4. dangling ID ๋ฐฉ์–ด (null skip) + List content = cached.stream() + .filter(Objects::nonNull) + .map(ProductCacheDto::toProductOutDto) + .toList(); + + return new ProductPageOutDto(content, page, size, idList.totalElements()); +} + +private boolean isCacheable(int page, int size) { + return page < MAX_CACHEABLE_PAGE && size == DEFAULT_PAGE_SIZE; +} +``` + +#### loadAndCacheDetails โ€” bulk projection (Read Model ๊ธฐ๋ฐ˜) + +```java +private List loadAndCacheDetails(List missedIds) { + // ProductQuerydslRepository์—์„œ Read Model bulk projection + List dtos = productQueryPort.findProductCacheDtosByIds(missedIds); + // ๊ฐ dto๋ฅผ ์ƒ์„ธ ์บ์‹œ์— PUT + for (ProductCacheDto dto : dtos) { + productCacheManager.put(DETAIL_KEY_PREFIX + dto.id(), dto, DETAIL_TTL); + } + return dtos; +} +``` + +#### findActiveIdsByBrandId (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) + +```java +// ProductQueryService์— ์ถ”๊ฐ€ โ€” Facade๊ฐ€ ํ˜ธ์ถœ +@Transactional(readOnly = true) +public List findActiveIdsByBrandId(Long brandId) { + return productReadModelRepository.findActiveIdsByBrandId(brandId); +} +``` + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: `findProductIdsByBrandId()` โ†’ `findActiveIdsByBrandId()`๋กœ ๋ช…์นญ ๋ณ€๊ฒฝ. +> ์œ„์น˜: `ProductCommandService`๊ฐ€ ์•„๋‹Œ `ProductQueryService` (์ˆœ์ˆ˜ read ๋ฉ”์„œ๋“œ). +> Repository: `ProductReadModelRepository` ์ธํ„ฐํŽ˜์ด์Šค์— ์ถ”๊ฐ€ โ†’ `ProductReadModelRepositoryImpl`์—์„œ JPA ์œ„์ž„. + +### 3.6 ProductCommandService write-through ์ „ํ™˜ + +#### ํŠธ๋ฆฌ๊ฑฐ๋ณ„ ์บ์‹œ ๊ฐฑ์‹  ๋ฒ”์œ„ + +| ํŠธ๋ฆฌ๊ฑฐ | ์ƒ์„ธ ์บ์‹œ | ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ | ๊ทผ๊ฑฐ | +|--------|----------|--------------|------| +| ์ข‹์•„์š” ยฑ1 | write-through | **๊ฐฑ์‹  ์•ˆ ํ•จ** (TTL 3๋ถ„ ์ž์—ฐ ๋งŒ๋ฃŒ) | ยฑ1๋กœ ์ˆœ์„œ ๋ณ€๋™ ๊ทนํžˆ ๋“œ๋ฌพ. ๊ณ ๋นˆ๋„ ํŠธ๋ฆฌ๊ฑฐ์—์„œ 12 SQL/๊ฑด์€ ๋น„์šฉ ๊ณผ๋‹ค | +| ์žฌ๊ณ  ์ฐจ๊ฐ | write-through | **๊ฐฑ์‹  ์•ˆ ํ•จ** (์ •๋ ฌ ๋ฌด๊ด€) | ์žฌ๊ณ ๋Š” ์ •๋ ฌ ๊ธฐ์ค€์ด ์•„๋‹˜ | +| ๊ฐ€๊ฒฉ ์ˆ˜์ • | write-through | write-through (PRICE_ASC ร— 6ํ‚ค) | ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ์€ ์ €๋นˆ๋„, ์ˆœ์„œ ๋ณ€๋™ ์ง์ ‘์  | +| ์ƒํ’ˆ ์ƒ์„ฑ | write-through | write-through (ALL ร— 18ํ‚ค) | ์ƒˆ ์ƒํ’ˆ์ด ๋ชฉ๋ก์— ๋ฐ˜์˜๋ผ์•ผ ํ•จ | +| ์ƒํ’ˆ ์‚ญ์ œ | evict (์‚ญ์ œ๋œ ์ƒํ’ˆ) | write-through (ALL ร— 18ํ‚ค) | ์‚ญ์ œ ์ƒํ’ˆ์ด ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐ๋ผ์•ผ ํ•จ | +| ๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • | write-through (ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์ „์ฒด) | ์—†์Œ (ID์— brandName ๋ฏธํฌํ•จ) | ๊ทนํžˆ ์ €๋นˆ๋„, ์ƒ์„ธ๋งŒ ์˜ํ–ฅ | + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: ์ข‹์•„์š”/์žฌ๊ณ  ๊ฒฝ๋กœ์—์„œ ID list write-through ์ œ๊ฑฐ. +> - ์ข‹์•„์š” 1๊ฑด๋‹น ๊ธฐ์กด ์„ค๊ณ„: 12 SQL (LIKES_DESC ร— 2 ร— 3pages ร— 2queries) โ†’ evictByPattern๋ณด๋‹ค ๋น„์šฉ ํผ +> - ์ข‹์•„์š” ยฑ1์ด ์ˆœ์„œ๋ฅผ ๋’ค์ง‘๋Š” ํ™•๋ฅ ์€ ๊ทนํžˆ ๋‚ฎ๊ณ , TTL 3๋ถ„์ด๋ฉด ์ถฉ๋ถ„ํžˆ ์ˆ˜๋ ด +> - ์žฌ๊ณ ๋Š” ์ •๋ ฌ ๊ธฐ์ค€ ์ž์ฒด๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ID list ์˜ํ–ฅ ์—†์Œ + +```java +// ์ข‹์•„์š” ์ฆ๊ฐ€ โ€” ์ƒ์„ธ ์บ์‹œ๋งŒ write-through (ID list ๊ฐฑ์‹  ์•ˆ ํ•จ) +@Transactional +public void increaseLikeCount(Long productId) { + readModelRepository.increaseLikeCount(productId); + + // write-through: ์ƒ์„ธ ์บ์‹œ๋งŒ (ID ๋ฆฌ์ŠคํŠธ๋Š” TTL ์ž์—ฐ ๋งŒ๋ฃŒ) + productCacheManager.refreshProductDetail(productId, () -> loadCacheDto(productId)); +} + +// ์ข‹์•„์š” ๊ฐ์†Œ โ€” ๋™์ผ +@Transactional +public void decreaseLikeCount(Long productId) { + readModelRepository.decreaseLikeCount(productId); + + // write-through: ์ƒ์„ธ ์บ์‹œ๋งŒ + productCacheManager.refreshProductDetail(productId, () -> loadCacheDto(productId)); +} + +// ์žฌ๊ณ  ์ฐจ๊ฐ โ€” ์ƒ์„ธ ์บ์‹œ๋งŒ (์žฌ๊ณ ๋Š” ์ •๋ ฌ ๋ฌด๊ด€) +@Transactional +public void decreaseStock(Long productId, Long quantity) { + Product product = productQueryRepository.findActiveByIdForUpdate(productId)...; + product.decreaseStock(quantity); + productCommandRepository.save(product); + readModelRepository.updateStock(productId, product.getStock().value()); + + // write-through: ์ƒ์„ธ ์บ์‹œ๋งŒ + productCacheManager.refreshProductDetail(productId, () -> loadCacheDto(productId)); +} +``` + +#### ๊ฐ€๊ฒฉ ๋ณ€๋™ ์‹œ ID list write-through (์ €๋นˆ๋„, Facade์—์„œ ํ˜ธ์ถœ) + +์ƒํ’ˆ ์ˆ˜์ •(๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ ํฌํ•จ)์€ `ProductCommandFacade`์—์„œ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, +read model ๋™๊ธฐํ™” ์ดํ›„์— ์บ์‹œ ๊ฐฑ์‹ ์„ ํ˜ธ์ถœํ•œ๋‹ค. + +```java +// ProductCommandFacade.updateProduct() +@Transactional +public AdminProductDetailOutDto updateProduct(Long id, AdminProductUpdateInDto inDto) { + Product product = productQueryService.findActiveById(id); + Product updatedProduct = productCommandService.updateProduct(product, inDto); + + Brand brand = brandQueryService.getBrandById(product.getBrandId()); + productCommandService.syncReadModel(updatedProduct, brand.getName().value()); + + // write-through: ์ƒ์„ธ ์บ์‹œ + productCacheManager.refreshProductDetail(id, () -> loadCacheDto(id)); + // write-through: ID ๋ฆฌ์ŠคํŠธ (PRICE_ASC ์ •๋ ฌ ์˜ํ–ฅ) + refreshIdListsForProduct(product.getBrandId(), ProductSortType.PRICE_ASC); + + return AdminProductDetailOutDto.from(updatedProduct, brand.getName().value()); +} + +// ์ƒํ’ˆ ์ƒ์„ฑ +@Transactional +public AdminProductDetailOutDto createProduct(AdminProductCreateInDto inDto) { + Brand brand = brandQueryService.getBrandById(inDto.brandId()); + Product savedProduct = productCommandService.createProduct(inDto); + productCommandService.syncReadModel(savedProduct, brand.getName().value()); + + // write-through: ์ƒ์„ธ ์บ์‹œ + ๋ชจ๋“  ์ •๋ ฌ ID ๋ฆฌ์ŠคํŠธ + productCacheManager.refreshProductDetail(savedProduct.getId(), () -> loadCacheDto(savedProduct.getId())); + refreshIdListsForAllSorts(savedProduct.getBrandId()); + + return AdminProductDetailOutDto.from(savedProduct, brand.getName().value()); +} + +// ์ƒํ’ˆ ์‚ญ์ œ +@Transactional +public void deleteProduct(Long id) { + Product product = productQueryService.findActiveById(id); + productCommandService.deleteProduct(product); + + // ์ƒ์„ธ ์บ์‹œ: evict (์‚ญ์ œ๋œ ์ƒํ’ˆ) + productCacheManager.deleteProductDetail(id); + // ID ๋ฆฌ์ŠคํŠธ: write-through (๋ชจ๋“  ์ •๋ ฌ) + refreshIdListsForAllSorts(product.getBrandId()); +} + +// --- private helpers --- + +// ํŠน์ • ์ •๋ ฌ์˜ ID ๋ฆฌ์ŠคํŠธ write-through +private void refreshIdListsForProduct(Long brandId, ProductSortType sortType) { + for (int page = 0; page < MAX_CACHEABLE_PAGE; page++) { + // brandId ์กฐ๊ฑด + all ์กฐ๊ฑด + String brandKey = buildIdListCacheKey(brandId, sortType, page, DEFAULT_PAGE_SIZE); + String allKey = buildIdListCacheKey(null, sortType, page, DEFAULT_PAGE_SIZE); + productCacheManager.refreshIdList(brandKey, () -> loadIdListFromDb(brandId, sortType, page, DEFAULT_PAGE_SIZE)); + productCacheManager.refreshIdList(allKey, () -> loadIdListFromDb(null, sortType, page, DEFAULT_PAGE_SIZE)); + } +} + +// ๋ชจ๋“  ์ •๋ ฌ์˜ ID ๋ฆฌ์ŠคํŠธ write-through +private void refreshIdListsForAllSorts(Long brandId) { + for (ProductSortType sort : ProductSortType.values()) { + refreshIdListsForProduct(brandId, sort); + } +} +``` + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: +> 1. ID list write-through ํ˜ธ์ถœ์„ Facade๋กœ ์ด๋™ (read model ๋™๊ธฐํ™” ์ดํ›„์— ์บ์‹œ ๊ฐฑ์‹  ๋ณด์žฅ) +> 2. `loadCacheDto()` ๋„ Facade์˜ private helper๋กœ โ€” Read Model์—์„œ projection 1ํšŒ ์กฐํšŒ +> 3. `List.of(null, brandId)` NPE ์ œ๊ฑฐ โ€” ๋ช…์‹œ์ ์œผ๋กœ brandKey/allKey ๋ถ„๋ฆฌ ํ˜ธ์ถœ + +#### loadCacheDto โ€” Read Model projection + +```java +// ProductCommandFacade (๋˜๋Š” ProductQueryService)์˜ private helper +private ProductCacheDto loadCacheDto(Long productId) { + // Read Model์—์„œ ์ง์ ‘ ProductCacheDto projection + return productQueryPort.findProductCacheDtoById(productId); +} +``` + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: `loadCacheDto()`์˜ ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ Product + BrandService ์กฐํ•ฉ์ด ์•„๋‹Œ +> `product_read_model` ํ…Œ์ด๋ธ”์—์„œ ์ง์ ‘ projection. brandName, description์ด ์ด๋ฏธ ๋น„์ •๊ทœํ™”๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ +> 1ํšŒ ์กฐํšŒ๋กœ ์™„์„ฑ ๊ฐ€๋Šฅ. ์ด๋ฅผ ์œ„ํ•ด `ProductQueryPort`์— `findProductCacheDtoById()` ์ถ”๊ฐ€. + +### 3.7 BrandCommandFacade ๋ธŒ๋žœ๋“œ๋ช… write-through + +```java +@Transactional +public AdminBrandDetailOutDto updateBrand(Long id, AdminBrandUpdateInDto inDto) { + Brand brand = brandQueryService.getBrandById(id); + Brand updatedBrand = brandCommandService.updateBrand(brand, inDto); + + // Read Model ๋ธŒ๋žœ๋“œ๋ช… ๋™๊ธฐํ™” + productCommandService.syncBrandNameInReadModel(id, updatedBrand.getName().value()); + + // ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through (ํ•ด๋‹น ๋ธŒ๋žœ๋“œ์˜ ์ „์ฒด ์ƒํ’ˆ) + List productIds = productQueryService.findActiveIdsByBrandId(id); + for (Long productId : productIds) { + productCacheManager.refreshProductDetail(productId, () -> loadCacheDto(productId)); + } + + return AdminBrandDetailOutDto.from(updatedBrand); +} +``` + +> **์„ค๊ณ„ ๋ณ€๊ฒฝ (Codex ํ”ผ๋“œ๋ฐฑ ๋ฐ˜์˜)**: `productCommandService.findProductIdsByBrandId()` โ†’ +> `productQueryService.findActiveIdsByBrandId()` (์ˆœ์ˆ˜ read ๋ฉ”์„œ๋“œ๋Š” QueryService์— ๋ฐฐ์น˜) + +### 3.8 QueryDSL tie-breaker + +```java +private OrderSpecifier[] getOrderSpecifiers(ProductSortType sortType) { + OrderSpecifier primary = switch (sortType) { + case LATEST -> readModel.createdAt.desc(); + case PRICE_ASC -> readModel.price.asc(); + case LIKES_DESC -> readModel.likeCount.desc(); + }; + // tie-breaker: ๋™๋ฅ  ์‹œ id ๋‚ด๋ฆผ์ฐจ์ˆœ (์ตœ์‹  ์ƒํ’ˆ ์šฐ์„ ) + OrderSpecifier secondary = readModel.id.desc(); + return new OrderSpecifier[]{ primary, secondary }; +} +``` + +### 3.9 ProductReadModelJpaRepository ์ถ”๊ฐ€ ๋ฉ”์„œ๋“œ + +```java +// ๋ธŒ๋žœ๋“œ ID๋กœ ํ™œ์„ฑ ์ƒํ’ˆ ID ๋ชฉ๋ก ์กฐํšŒ (๋ธŒ๋žœ๋“œ๋ช… write-through์šฉ) +@Query("SELECT e.id FROM ProductReadModelEntity e WHERE e.brandId = :brandId AND e.deletedAt IS NULL") +List findActiveIdsByBrandId(@Param("brandId") Long brandId); +``` + +### 3.10 ProductQueryPort / QuerydslRepository ์ถ”๊ฐ€ ๋ฉ”์„œ๋“œ + +```java +// ProductQueryPort ์ธํ„ฐํŽ˜์ด์Šค์— ์ถ”๊ฐ€ +ProductCacheDto findProductCacheDtoById(Long productId); +List findProductCacheDtosByIds(List productIds); + +// ProductQuerydslRepository โ€” Read Model์—์„œ ProductCacheDto projection +public ProductCacheDto findProductCacheDtoById(Long productId) { + return queryFactory.select(Projections.constructor(ProductCacheDto.class, + readModel.id, readModel.brandId, readModel.brandName, readModel.name, + readModel.price, readModel.stock, readModel.description, readModel.likeCount)) + .from(readModel) + .where(readModel.id.eq(productId).and(readModel.deletedAt.isNull())) + .fetchOne(); +} + +public List findProductCacheDtosByIds(List productIds) { + return queryFactory.select(Projections.constructor(ProductCacheDto.class, + readModel.id, readModel.brandId, readModel.brandName, readModel.name, + readModel.price, readModel.stock, readModel.description, readModel.likeCount)) + .from(readModel) + .where(readModel.id.in(productIds).and(readModel.deletedAt.isNull())) + .fetch(); +} +``` + +--- + +## 4. ID ๋ฆฌ์ŠคํŠธ ์กฐํšŒ ์ฟผ๋ฆฌ + +write-through ์‹œ ID ๋ฆฌ์ŠคํŠธ๋ฅผ ์žฌ์ƒ์„ฑํ•˜๋ ค๋ฉด "ํ•ด๋‹น ์ •๋ ฌ + ํ•„ํ„ฐ ์กฐ๊ฑด์œผ๋กœ page N์˜ ID ๋ชฉ๋ก"์„ ์กฐํšŒํ•ด์•ผ ํ•œ๋‹ค. + +```java +// ProductQuerydslRepository์— ์ถ”๊ฐ€ +public IdListCacheEntry searchProductIds(ProductSearchCriteria criteria, PageCriteria pageCriteria) { + + QProductReadModelEntity readModel = QProductReadModelEntity.productReadModelEntity; + + // ํ™œ์„ฑ ์ƒํ’ˆ ํ•„ํ„ฐ + BooleanExpression where = readModel.deletedAt.isNull(); + if (criteria.brandId() != null) { + where = where.and(readModel.brandId.eq(criteria.brandId())); + } + + // ์ด ๊ฐœ์ˆ˜ + long total = queryFactory.select(readModel.id) + .from(readModel) + .where(where) + .fetchCount(); + + // ID ๋ชฉ๋ก (์ •๋ ฌ + ํŽ˜์ด์ง€๋„ค์ด์…˜) + List ids = queryFactory.select(readModel.id) + .from(readModel) + .where(where) + .orderBy(getOrderSpecifiers(criteria.sortType())) + .offset((long) pageCriteria.page() * pageCriteria.size()) + .limit(pageCriteria.size()) + .fetch(); + + return new IdListCacheEntry(ids, total); +} +``` + +--- + +## 5. ๊ตฌํ˜„ Phase + +### Phase 1: Foundation (์ธํ”„๋ผ ๋ณ€๊ฒฝ) +1. `ProductCacheConstants`, `ProductCacheDto`, `IdListCacheEntry` ์ƒ์„ฑ +2. `ProductCacheManager`์— ์‹ ๊ทœ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ (refreshProductDetail, refreshIdList, mgetProductDetails, deleteProductDetail) +3. `ProductReadModelRepository` ์ธํ„ฐํŽ˜์ด์Šค + ๊ตฌํ˜„์ฒด์— `findActiveIdsByBrandId()` ์ถ”๊ฐ€ +4. `ProductReadModelJpaRepository`์— `findActiveIdsByBrandId()` ์ถ”๊ฐ€ +5. `ProductQueryPort`์— `findProductCacheDtoById()`, `findProductCacheDtosByIds()` ์ถ”๊ฐ€ +6. `ProductQuerydslRepository`์— tie-breaker ์ถ”๊ฐ€ + `searchProductIds()` + `findProductCacheDto*` ์ถ”๊ฐ€ +7. ๊ธฐ์กด ๊ธฐ๋Šฅ์— ์˜ํ–ฅ ์—†์Œ โ€” ์ƒˆ ์ฝ”๋“œ๋งŒ ์ถ”๊ฐ€ + +### Phase 2: Write path (write-through ์ „ํ™˜) +1. `ProductCommandService`์˜ ์ข‹์•„์š”/์žฌ๊ณ  ๋ฉ”์„œ๋“œ: evict โ†’ ์ƒ์„ธ ์บ์‹œ write-through๋กœ ๊ต์ฒด (ID list ๊ฐฑ์‹  ์•ˆ ํ•จ) +2. `ProductCommandFacade`์˜ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ: read model ๋™๊ธฐํ™” ์ดํ›„ write-through ํ˜ธ์ถœ +3. `BrandCommandFacade.updateBrand()`์— ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through ์ถ”๊ฐ€ +4. `ProductQueryService`์— `findActiveIdsByBrandId()` ์ถ”๊ฐ€ +5. ๊ธฐ์กด `evictProductDetailCache()` ๋ฉ”์„œ๋“œ โ†’ `deleteProductDetail()`๋กœ ๊ต์ฒด +6. ~~**์ „ํ™˜๊ธฐ ๋ณ‘ํ–‰**: write-through์™€ ํ•จ๊ป˜ ๊ธฐ์กด ํ‚ค evict ๋ณ‘ํ–‰~~ โ†’ ์ „ํ™˜ ์™„๋ฃŒ, old key ๋ณ‘ํ–‰ ๋ถˆํ•„์š” + +### Phase 3: Read path (2๊ณ„์ธต ์กฐํšŒ) +1. `ProductQueryService.searchProducts()` ์ „๋ฉด ๊ต์ฒด (ID ๋ฆฌ์ŠคํŠธ โ†’ MGET ํ๋ฆ„) +2. cacheable guard ์ ์šฉ (`page < MAX_CACHEABLE_PAGE && size == DEFAULT_PAGE_SIZE`) +3. `ProductQueryFacade` ์ƒ์„ธ ์กฐํšŒ ์‹œ `ProductCacheDto` ์‚ฌ์šฉ +4. TTL ๋ณ€๊ฒฝ (list 3๋ถ„, detail 2๋ถ„) +5. ๊ธฐ์กด `products:list:*` ์บ์‹œ ํ‚ค โ†’ `products:ids:v1:*` ์ „ํ™˜ +6. ๊ธฐ์กด `product:{id}` ์บ์‹œ ํ‚ค โ†’ `product:v1:{id}` ์ „ํ™˜ +7. ~~Phase 2์˜ old key evict ๋ณ‘ํ–‰ ์ฝ”๋“œ ์ œ๊ฑฐ~~ โ†’ ๋ณ‘ํ–‰ ์ฝ”๋“œ ์—†์ด ์ง์ ‘ ์ „ํ™˜ ์™„๋ฃŒ +8. `evictByPattern()` ๋ฉ”์„œ๋“œ ProductCacheManager์—์„œ ์ œ๊ฑฐ ์™„๋ฃŒ + +--- + +## 6. ํ…Œ์ŠคํŠธ ์ „๋žต + +### Phase 1 ํ…Œ์ŠคํŠธ +- `ProductCacheDto` ๋ณ€ํ™˜ ํ…Œ์ŠคํŠธ (toProductOutDto, toProductDetailOutDto) +- `ProductQuerydslRepository.searchProductIds()` ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ +- `ProductQuerydslRepository.findProductCacheDtoById/sByIds()` ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ +- tie-breaker ์ •๋ ฌ ๊ฒ€์ฆ (๋™๋ฅ  ์‹œ id ๋‚ด๋ฆผ์ฐจ์ˆœ) + +### Phase 2 ํ…Œ์ŠคํŠธ +- `ProductCommandService` ์ข‹์•„์š”/์žฌ๊ณ  ๋ฉ”์„œ๋“œ์˜ ์ƒ์„ธ ์บ์‹œ write-through ํ˜ธ์ถœ ๊ฒ€์ฆ (mock ๊ธฐ๋ฐ˜) +- `ProductCommandFacade` ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ์˜ write-through ํ˜ธ์ถœ ์ˆœ์„œ ๊ฒ€์ฆ +- `BrandCommandFacade.updateBrand()` ์‹œ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ ๊ฐฑ์‹  ๊ฒ€์ฆ +- ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ: ์ข‹์•„์š” โ†’ ์ƒ์„ธ ์บ์‹œ ๊ฐ’ ๋ณ€๊ฒฝ ํ™•์ธ + +### Phase 3 ํ…Œ์ŠคํŠธ +- `ProductQueryService.searchProducts()` 2๊ณ„์ธต ํ๋ฆ„ ๊ฒ€์ฆ + - ID ๋ฆฌ์ŠคํŠธ hit + ์ƒ์„ธ all hit + - ID ๋ฆฌ์ŠคํŠธ hit + ์ƒ์„ธ partial miss + - ID ๋ฆฌ์ŠคํŠธ miss + - dangling ID ๋ฐฉ์–ด +- cacheable guard: page 3 ์ด์ƒ ๋˜๋Š” size != 20์ผ ๋•Œ ์บ์‹œ ๋ฏธ์‚ฌ์šฉ +- E2E: ์ƒํ’ˆ ์ƒ์„ฑ โ†’ ๋ชฉ๋ก ์กฐํšŒ โ†’ write-through ๋ฐ˜์˜ ํ™•์ธ + +--- + +## 7. ์ฃผ์˜์‚ฌํ•ญ + +### ์ „ํ™˜ ์™„๋ฃŒ ์ƒํƒœ +- old key ๋ณ‘ํ–‰ ๋กœ์ง ์—†์ด ์ง์ ‘ ์ „ํ™˜ ์™„๋ฃŒ +- `evictByPattern()` ๋ฉ”์„œ๋“œ ProductCacheManager์—์„œ ์ œ๊ฑฐ ์™„๋ฃŒ +- old key(`products:list:*`, `product:{id}`) ๊ด€๋ จ artifact ์ •๋ฆฌ ์™„๋ฃŒ + +### ๊ณ ๋นˆ๋„ ํŠธ๋ฆฌ๊ฑฐ ์ตœ์ ํ™” +- ์ข‹์•„์š”/์žฌ๊ณ  ๊ฒฝ๋กœ๋Š” **์ƒ์„ธ ์บ์‹œ๋งŒ write-through**, ID ๋ฆฌ์ŠคํŠธ๋Š” TTL 3๋ถ„ ์ž์—ฐ ๋งŒ๋ฃŒ์— ์œ„์ž„ +- ์ข‹์•„์š” ยฑ1๋กœ ๋ชฉ๋ก ์ˆœ์„œ๊ฐ€ ๋’ค์ง‘ํžˆ๋Š” ํ™•๋ฅ ์€ ๊ทนํžˆ ๋‚ฎ์œผ๋ฏ€๋กœ eventual consistency ํ—ˆ์šฉ + +### PER ์ •์ฑ… +- PER(Probabilistic Early Refresh)๋Š” ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ์—์„œ ์ œ๊ฑฐ, ์ƒ์„ธ ์บ์‹œ์—์„œ๋งŒ ์œ ์ง€ +- write-through๊ฐ€ ์ฃผ๋ ฅ์ด๋ฏ€๋กœ PER์˜ ์—ญํ• ์ด ์ค„์–ด๋“ฆ. Redis ์žฅ์•  ์‹œ PER๋„ ๋™์ผํ•˜๊ฒŒ ์‹คํŒจํ•˜๋ฏ€๋กœ ์ถ”๊ฐ€ ๋ฐฉ์–ด ํšจ๊ณผ ์—†์Œ + +### ์บ์‹œ ์“ฐ๊ธฐ ์‹œ์  +- ์บ์‹œ write-through๋Š” TX ๋‚ด์—์„œ ์‹คํ–‰๋จ (๊ธฐ์กด evict์™€ ๋™์ผํ•œ ์ˆ˜์ค€) +- best-effort โ€” TX rollback ์‹œ phantom cache๊ฐ€ ๋‚จ์„ ์ˆ˜ ์žˆ์œผ๋‚˜ TTL 2๋ถ„ ๋‚ด ์ž์—ฐ ์†Œ๋ฉธ +- afterCommit ํŒจํ„ด์€ ์ฝ”๋“œ ๋ณต์žก๋„ ๋Œ€๋น„ ์‹ค์ต์ด ๋‚ฎ์œผ๋ฏ€๋กœ ํ˜„์žฌ scope์—์„œ ์ œ์™ธ + +### Redis replica lag +- ์ฝ๊ธฐ replica-preferred, ์“ฐ๊ธฐ master ๊ตฌ์กฐ์ด๋ฏ€๋กœ write-through ์งํ›„ ์ฝ๊ธฐ์—์„œ stale ๊ฐ€๋Šฅ +- write-through๋กœ miss ์ž์ฒด๊ฐ€ ์ค„์–ด๋“ค์–ด ์‹ค์งˆ์  ์˜ํ–ฅ์€ ๋ฏธ๋ฏธ diff --git a/round5-docs/07-cache-eviction-analysis.md b/round5-docs/07-cache-eviction-analysis.md new file mode 100644 index 000000000..24cbc0e19 --- /dev/null +++ b/round5-docs/07-cache-eviction-analysis.md @@ -0,0 +1,684 @@ +# ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต ๋ถ„์„ โ€” evictByPattern์˜ ํ•จ์ •๊ณผ ๋Œ€์•ˆ + +## 1. ๋ฌธ์ œ ์ƒํ™ฉ + +### 1.1 ๋ฌธ์ œ ๋ฐœ๊ฒฌ ๊ฒฝ์œ„ + +๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • ์‹œ ์ƒํ’ˆ ์บ์‹œ ๋ฌดํšจํ™” ๋ˆ„๋ฝ์„ ์ˆ˜์ •ํ•˜๋ ค๋‹ค, ๊ทผ๋ณธ์ ์ธ ์„ค๊ณ„ ์ด์Šˆ๋ฅผ ๋ฐœ๊ฒฌํ–ˆ๋‹ค. + +- **์ง์ ‘ ์›์ธ**: `BrandCommandFacade.updateBrand()`์—์„œ Read Model์€ ๋™๊ธฐํ™”ํ•˜๋ฉด์„œ ์ƒํ’ˆ ์บ์‹œ๋Š” ๋ฌดํšจํ™”ํ•˜์ง€ ์•Š์Œ +- **๊ทผ๋ณธ ์›์ธ**: ์ˆ˜์ •๋œ entity์˜ ์บ์‹œ๋งŒ ์ •ํ™•ํžˆ ์ฐพ์•„์„œ evictํ•  ์ˆ˜ ์—†๋Š” ๊ตฌ์กฐ + +### 1.2 ์™œ ์ˆ˜์ •๋œ entity์˜ ์บ์‹œ๋งŒ ์ •ํ™•ํžˆ evictํ•  ์ˆ˜ ์—†๋Š”๊ฐ€ + +#### ์ƒ์„ธ ์บ์‹œ (`product:{id}`) + +์บ์‹œ ํ‚ค ๊ตฌ์กฐ: + +``` +product:1 โ†’ { id:1, brandId:5, brandName:"๋‚˜์ดํ‚ค", ... } +product:2 โ†’ { id:2, brandId:5, brandName:"๋‚˜์ดํ‚ค", ... } +product:3 โ†’ { id:3, brandId:7, brandName:"์•„๋””๋‹ค์Šค", ... } +... +product:9999 โ†’ { id:9999, brandId:5, brandName:"๋‚˜์ดํ‚ค", ... } +``` + +๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • ์‹œ๋‚˜๋ฆฌ์˜ค: `brandId=5`์˜ ์ด๋ฆ„์„ "๋‚˜์ดํ‚ค" โ†’ "NIKE"๋กœ ๋ณ€๊ฒฝ + +๋ฌดํšจํ™”ํ•ด์•ผ ํ•  ์บ์‹œ๋Š” `product:1`, `product:2`, `product:9999` (brandId=5์ธ ๊ฒƒ๋“ค). +ํ•˜์ง€๋งŒ **Redis ์บ์‹œ ํ‚ค์—๋Š” brandId๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค.** ํ‚ค๋Š” `product:{productId}`์ด๊ณ , brandId๋Š” **๊ฐ’(value) ๋‚ด๋ถ€**์—๋งŒ ์กด์žฌํ•œ๋‹ค. + +Redis์—์„œ "๊ฐ’ ๋‚ด๋ถ€์˜ ํŠน์ • ํ•„๋“œ๋กœ ํ‚ค๋ฅผ ์—ญํƒ์ƒ‰"ํ•˜๋Š” ๋ฐฉ๋ฒ•์€ ์—†๋‹ค: + +``` +redis> FIND_KEYS_WHERE value.brandId == 5 // โ† ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ช…๋ น +``` + +#### ๋ชฉ๋ก ์บ์‹œ (`products:list:*`) + +``` +products:list:all:LATEST:0:20 โ†’ [๋‚˜์ดํ‚ค ์ƒํ’ˆ + ์•„๋””๋‹ค์Šค ์ƒํ’ˆ ํ˜ผ์žฌ] +products:list:all:LIKES_DESC:0:20 โ†’ [๋‚˜์ดํ‚ค ์ƒํ’ˆ + ์•„๋””๋‹ค์Šค ์ƒํ’ˆ ํ˜ผ์žฌ] +products:list:5:LATEST:0:20 โ†’ [๋‚˜์ดํ‚ค ์ƒํ’ˆ๋งŒ] +``` + +`brandId=5` ํ•„ํ„ฐ ๋ชฉ๋ก๋งŒ ์ง€์šฐ๋ฉด ๋  ๊ฒƒ ๊ฐ™์ง€๋งŒ, `all` ํ‚ค์—๋„ ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ์ด ์„ž์—ฌ ์žˆ์–ด์„œ ๊ฒฐ๊ตญ ์ „์ฒด๋ฅผ ๋ฌดํšจํ™”ํ•ด์•ผ ํ•œ๋‹ค. + +#### ์„ ํƒ์ง€ + +| ๋ฐฉ์‹ | ์„ค๋ช… | ๋ฌธ์ œ | +|------|------|------| +| **(a)** `product:*` ์ „์ฒด SCAN + ์—ญ์ง๋ ฌํ™” | ๊ฐ ๊ฐ’์„ ์ฝ์–ด์„œ brandId ํ™•์ธ โ†’ ๋งค์นญ๋งŒ ์‚ญ์ œ | O(N) ํ’€์Šค์บ”, ์‚ฌ์‹ค์ƒ ๋ถˆ๊ฐ€๋Šฅ | +| **(b)** `product:*` ์ „์ฒด evict | ๋‹จ์ˆœํ•˜์ง€๋งŒ ๋ชจ๋“  ์บ์‹œ ๋‚ ๋ฆผ | ๊ฐ•์ œ ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ์œ ๋ฐœ | +| **(c)** ๋ณ„๋„ ์—ญ์ธ๋ฑ์Šค ๊ด€๋ฆฌ | `brand:5:products โ†’ [1, 2, 9999]` | ๊ด€๋ฆฌ ๋ณต์žก๋„ ์ฆ๊ฐ€ | + +### 1.3 ์ „์ฒด evict ํŒจํ„ด์€ ๊ฐ•์ œ ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ๋ฅผ ๋งŒ๋“ ๋‹ค + +`evictByPattern("products:list:*")`๊ฐ€ ์‹คํ–‰๋˜๋ฉด: + +``` +์‹œ์  T: ๋ชฉ๋ก ์บ์‹œ ์ „์ฒด ์‚ญ์ œ +์‹œ์  T+1ms: ์‚ฌ์šฉ์ž A ์š”์ฒญ โ†’ cache miss โ†’ DB ์ฟผ๋ฆฌ +์‹œ์  T+2ms: ์‚ฌ์šฉ์ž B ์š”์ฒญ โ†’ cache miss โ†’ DB ์ฟผ๋ฆฌ +์‹œ์  T+3ms: ์‚ฌ์šฉ์ž C ์š”์ฒญ โ†’ cache miss โ†’ DB ์ฟผ๋ฆฌ +... +โ†’ ๋™์‹œ N๋ช…์ด ์ „๋ถ€ DB๋กœ ๋ชฐ๋ฆผ = ์Šคํƒฌํ”ผ๋“œ +``` + +#### ํ˜„์žฌ ์ฝ”๋“œ์—์„œ์˜ ํ˜ธ์ถœ ๋นˆ๋„ + +| ํŠธ๋ฆฌ๊ฑฐ | ๋นˆ๋„ | `evictByPattern("products:list:*")` ํ˜ธ์ถœ | +|--------|------|:---:| +| ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ | **๋†’์Œ** (์‚ฌ์šฉ์ž ํ–‰๋™) | ๋งค๋ฒˆ ํ˜ธ์ถœ | +| ์žฌ๊ณ  ์ฐจ๊ฐ (์ฃผ๋ฌธ) | **์ค‘๊ฐ„** | ๋งค๋ฒˆ ํ˜ธ์ถœ | +| ์ƒํ’ˆ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ | ๋‚ฎ์Œ (๊ด€๋ฆฌ์ž) | ๋งค๋ฒˆ ํ˜ธ์ถœ | + +**์ข‹์•„์š” ํ•œ ๋ฒˆ ๋ˆ„๋ฅผ ๋•Œ๋งˆ๋‹ค ์ „์ฒด ๋ชฉ๋ก ์บ์‹œ๊ฐ€ ๋‚ ์•„๊ฐ„๋‹ค.** ํŠธ๋ž˜ํ”ฝ์ด ๋†’์œผ๋ฉด ์บ์‹œ๊ฐ€ ์˜๋ฏธ ์—†์–ด์ง€๋Š” ์ˆ˜์ค€์ด๋‹ค. + +#### ๊ทผ๋ณธ ์›์ธ + +**์บ์‹œ ํ‚ค ์„ค๊ณ„์™€ ๋ฌดํšจํ™” ๋ฒ”์œ„์˜ ๋ถˆ์ผ์น˜.** ๋ชฉ๋ก ์บ์‹œ๋ฅผ ํ•˜๋‚˜์˜ ํฐ ๋ฉ์–ด๋ฆฌ๋กœ ์บ์‹ฑํ•˜๋ฉด์„œ, ๊ตฌ์„ฑ ์š”์†Œ(๊ฐœ๋ณ„ ์ƒํ’ˆ)๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ ์ „์ฒด๋ฅผ ๋‚ ๋ ค์•ผ ํ•˜๋Š” ๊ตฌ์กฐ. + +--- + +## 2. ๋ชฉํ‘œ์™€ ๋ชฉํ‘œ ์„ ์ • ์ด์œ  + +**๋ชฉํ‘œ:** ์บ์‹œ ๋ฌดํšจํ™”์˜ blast radius๋ฅผ ์ตœ์†Œํ™”ํ•˜๋ฉด์„œ, ๋ชฉ๋ก(PLP)/์ƒ์„ธ(PDP) ์บ์‹œ์˜ ํžˆํŠธ์œจ์„ ์•ˆ์ •์ ์œผ๋กœ ์œ ์ง€ํ•œ๋‹ค. + +**๋ชฉํ‘œ ์„ ์ • ์ด์œ :** + +| ํ˜„์ƒ | ์˜ํ–ฅ | +|------|------| +| `evictByPattern("products:list:*")`์ด ์ข‹์•„์š” 1๊ฑด๋งˆ๋‹ค ์‹คํ–‰ | ์บ์‹œ ํžˆํŠธ์œจ โ‰ˆ 0%, ์บ์‹œ ์กด์žฌ ์˜๋ฏธ ์ƒ์‹ค | +| ๋ชจ๋“  ๋ชฉ๋ก ํ‚ค๊ฐ€ ๋™์‹œ์— miss โ†’ ํ‚ค ์ˆ˜๋งŒํผ DB ์ฟผ๋ฆฌ ํญ์ฆ | ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์–ด(LocalCacheLock)๋กœ๋„ ํ‚ค ๊ฐ„ ๋™์‹œ miss๋Š” ๋ฐฉ์–ด ๋ถˆ๊ฐ€ | +| ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” ๋ˆ„๋ฝ | ๊ฐ’ ๋‚ด๋ถ€ ํ•„๋“œ ๊ธฐ๋ฐ˜ ์—ญํƒ์ƒ‰์ด ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ์  ํ•œ๊ณ„ | +| Redis ์˜ค๋ฒ„ํ—ค๋“œ(์ง๋ ฌํ™”/๋„คํŠธ์›Œํฌ)๋งŒ ์ถ”๊ฐ€๋˜๊ณ  ์ด๋“ ์—†์Œ | "๋งˆ์ด๋„ˆ์Šค ์บ์‹œ" โ€” ์บ์‹œ๊ฐ€ ์—†๋Š” ๊ฒƒ๋ณด๋‹ค ๋‚˜์œ ์ƒํƒœ | + +**ํ•ต์‹ฌ:** ๋ฌธ์ œ๋Š” "์บ์‹œ๋ฅผ ์“ธ ๊ฒƒ์ธ๊ฐ€"๊ฐ€ ์•„๋‹ˆ๋ผ **"๋ฌดํšจํ™”๋ฅผ ์–ด๋–ป๊ฒŒ ํ•  ๊ฒƒ์ธ๊ฐ€"**์ด๋‹ค. + +--- + +## 3. ํ•ด๊ฒฐ์ฑ… ๋ถ„์„ + +### 3.0 ํŒ๋‹จ ๊ธฐ์ค€ ํ”„๋ ˆ์ž„์›Œํฌ + +์บ์‹œ ์ „๋žต์„ ํŒ๋‹จํ•  ๋•Œ ๋จผ์ € ๋ฌผ์–ด์•ผ ํ•  ์งˆ๋ฌธ: + +> **"์ด ์บ์‹œ์˜ ๋ฌดํšจํ™” ๋นˆ๋„๊ฐ€, ์บ์‹œ๋กœ ์–ป๋Š” ์ด๋“์„ ์ƒ์‡„ํ•˜์ง€ ์•Š๋Š”๊ฐ€?"** + +์„ธ ๊ฐ€์ง€ ์ถ•์œผ๋กœ ๋ถ„ํ•ด: + +| ์ถ• | ์„ค๋ช… | ํ•ต์‹ฌ ์งˆ๋ฌธ | +|---|------|----------| +| **Hit Rate** | ์บ์‹œ๊ฐ€ ์‹ค์ œ๋กœ ๋„์›€์ด ๋˜๋Š”๊ฐ€ | ์ฝ๊ธฐ ๋นˆ๋„ รท ๋ฌดํšจํ™” ๋นˆ๋„ ๋น„์œจ์ด ์ถฉ๋ถ„ํ•œ๊ฐ€? | +| **Miss Cost** | cache miss ๋ฐœ์ƒ ์‹œ DB ์ฟผ๋ฆฌ ๋น„์šฉ | DB ์ฟผ๋ฆฌ๊ฐ€ ๋А๋ ค์„œ ์บ์‹œ๊ฐ€ ํ•„์š”ํ•œ๊ฐ€, ์ด๋ฏธ ์ถฉ๋ถ„ํžˆ ๋น ๋ฅธ๊ฐ€? | +| **Invalidation Blast Radius** | ํ•œ ๋ฒˆ ๋ฌดํšจํ™” ์‹œ ๋ช‡ ๊ฐœ์˜ ํ‚ค๊ฐ€ ๋‚ ์•„๊ฐ€๋Š”๊ฐ€ | 1๊ฐœ๋งŒ ๋‚ ๋ฆฌ๋Š”๊ฐ€, ์ˆ˜๋ฐฑ ๊ฐœ๋ฅผ ๋‚ ๋ฆฌ๋Š”๊ฐ€? | + +ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์— ๋Œ€์ž…: + +| ์ถ• | ์ƒํ’ˆ ์ƒ์„ธ (`product:{id}`) | ์ƒํ’ˆ ๋ชฉ๋ก (`products:list:*`) | +|---|---|---| +| ๋ฌดํšจํ™” ๋นˆ๋„ | ๋‚ฎ์Œ (ํ•ด๋‹น ์ƒํ’ˆ ๋ณ€๊ฒฝ ์‹œ๋งŒ) | **๋งค์šฐ ๋†’์Œ** (์•„๋ฌด ์ƒํ’ˆ์˜ ์ข‹์•„์š”/์žฌ๊ณ  ๋ณ€๊ฒฝ๋งˆ๋‹ค) | +| Miss Cost | ์ค‘๊ฐ„ (๋‹จ๊ฑด ์ฟผ๋ฆฌ) | **๋งค์šฐ ๋‚ฎ์Œ** (Read Model 0.85ms) | +| Blast Radius | 1๊ฐœ ํ‚ค | **์ „์ฒด ํ‚ค** (์ˆ˜์‹ญ~์ˆ˜๋ฐฑ ๊ฐœ) | + +**๋ชฉ๋ก ์บ์‹œ๋Š” ์„ธ ์ถ• ๋ชจ๋‘์—์„œ ๋ถˆ๋ฆฌํ•˜๋‹ค.** + +### 3.1 ์ „๋žต 1: ํ‚ค์— ์ •ํ™•ํ•œ ํ•„๋“œ๊ฐ’ ํฌํ•จ โ†’ ํƒ€๊ฒŸ evict + +ํ‚ค ์ž์ฒด์— ๋ฐ์ดํ„ฐ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๋„ฃ์–ด์„œ, ๋ณ€๊ฒฝ ์‹œ ํ•ด๋‹น ํ‚ค๋งŒ ์ •ํ™•ํžˆ ์ฐพ์•„ ์‚ญ์ œํ•˜๋Š” ๋ฐฉ์‹. + +**๊ทผ๋ณธ์  ํ•œ๊ณ„๊ฐ€ ์žˆ๋‹ค.** + +๋ชฉ๋ก ์บ์‹œ๋Š” "์ •๋ ฌ๋œ N๋ฒˆ์งธ ํŽ˜์ด์ง€"์ด๊ธฐ ๋•Œ๋ฌธ์—, ๊ฐœ๋ณ„ ์ƒํ’ˆ์˜ ๋ณ€๊ฒฝ์ด ์–ด๋–ค ํŽ˜์ด์ง€์— ์˜ํ–ฅ์„ ์ฃผ๋Š”์ง€ ์‚ฌ์ „์— ์•Œ ์ˆ˜ ์—†๋‹ค: + +``` +[๋ณ€๊ฒฝ ์ „] LIKES_DESC ์ •๋ ฌ +page 0: [์ƒํ’ˆA(100์ข‹์•„์š”), ์ƒํ’ˆB(80), ์ƒํ’ˆC(60)] +page 1: [์ƒํ’ˆD(50), ์ƒํ’ˆE(40), ์ƒํ’ˆF(30)] + +[์ƒํ’ˆE๊ฐ€ ์ข‹์•„์š” +70 ๋ฐ›์Œ] +page 0: [์ƒํ’ˆE(110), ์ƒํ’ˆA(100), ์ƒํ’ˆB(80)] โ† page 0 ๋‚ด์šฉ ๋ณ€๊ฒฝ +page 1: [์ƒํ’ˆC(60), ์ƒํ’ˆD(50), ์ƒํ’ˆF(30)] โ† page 1 ๋‚ด์šฉ๋„ ๋ณ€๊ฒฝ +``` + +์ƒํ’ˆE์˜ ์ข‹์•„์š”๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์„ ๋ฟ์ธ๋ฐ, **๋ชจ๋“  ํŽ˜์ด์ง€์˜ ๋‚ด์šฉ์ด ๋ฐ€๋ ค๋‚œ๋‹ค.** ํ‚ค์— ์•„๋ฌด๋ฆฌ ๋งŽ์€ ์ •๋ณด๋ฅผ ๋„ฃ์–ด๋„ "์ด ๋ณ€๊ฒฝ์ด ์–ด๋А ํŽ˜์ด์ง€์— ์˜ํ–ฅ์„ ์ฃผ๋Š”๊ฐ€"๋Š” ์ „์ฒด๋ฅผ ๋‹ค์‹œ ์ •๋ ฌํ•˜์ง€ ์•Š์œผ๋ฉด ์•Œ ์ˆ˜ ์—†๋‹ค. + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ํ‚ค ์„ค๊ณ„ ๋ณต์žก๋„ โ†‘, ์ •๋ ฌ/ํŽ˜์ด์ง€๋„ค์ด์…˜์—์„œ๋Š” ๊ทผ๋ณธ์ ์œผ๋กœ ์ž‘๋™ํ•˜์ง€ ์•Š์Œ | +| ์ถ”์ฒœ ์ƒํ™ฉ | ์ •๋ ฌ์ด ์—†๊ณ , ํ•„ํ„ฐ ์กฐ๊ฑด์ด ๊ณ ์ •๋œ ๋‹จ๊ฑด/์†Œ์ˆ˜๊ฑด ์กฐํšŒ (์˜ˆ: `user:{id}:profile`) | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | **๋ถ€์ ํ•ฉ** โ€” ์ •๋ ฌ+ํŽ˜์ด์ง€๋„ค์ด์…˜ ์กฐํ•ฉ์—์„œ ํƒ€๊ฒŸ evict๊ฐ€ ๋ถˆ๊ฐ€๋Šฅ | + +### 3.2 ์ „๋žต 2: ์ „์ฒด evict + ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์–ด ์‹ ๋ขฐ + +์–ด์ฐจํ”ผ LocalCacheLock + PER์ด ์žˆ์œผ๋‹ˆ, ์ „์ฒด evictํ•ด๋„ ์•ˆ์ „ํ•˜๋‹ค๋Š” ์ ‘๊ทผ. + +**๋ฐ˜์€ ๋งž๊ณ  ๋ฐ˜์€ ํ‹€๋ฆฌ๋‹ค.** + +ํ˜„์žฌ ์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์–ด๊ฐ€ ๋ง‰์•„์ฃผ๋Š” ๊ฒƒ: +``` +products:list:all:LATEST:0:20 โ† ๋™์‹œ 100๋ช… ์š”์ฒญ + โ†’ LocalCacheLock์ด 1๋ช…๋งŒ DB ์กฐํšŒ โ†’ ๋‚˜๋จธ์ง€ 99๋ช… ๋Œ€๊ธฐ ํ›„ ์บ์‹œ ์‚ฌ์šฉ +``` + +ํ•˜์ง€๋งŒ ์ „์ฒด evict ์‹œ ๋ฐœ์ƒํ•˜๋Š” ๊ฒƒ: +``` +products:list:all:LATEST:0:20 โ† 1 DB ์ฟผ๋ฆฌ (Lock์ด ๋ฐฉ์–ด) +products:list:all:LATEST:1:20 โ† 1 DB ์ฟผ๋ฆฌ (Lock์ด ๋ฐฉ์–ด) +products:list:all:LIKES_DESC:0:20 โ† 1 DB ์ฟผ๋ฆฌ (Lock์ด ๋ฐฉ์–ด) +products:list:5:LATEST:0:20 โ† 1 DB ์ฟผ๋ฆฌ (Lock์ด ๋ฐฉ์–ด) +... +โ†’ "ํ‚ค ํ•˜๋‚˜๋‹น 1 ์ฟผ๋ฆฌ"์ด์ง€๋งŒ, ํ‚ค๊ฐ€ ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ ๊ฐœ๋ฉด ์—ฌ์ „ํžˆ DB ๋ถ€ํ•˜ ํญ์ฆ +``` + +LocalCacheLock์€ **๊ฐ™์€ ํ‚ค**์— ๋Œ€ํ•œ ์ค‘๋ณต ์ฟผ๋ฆฌ๋ฅผ ๋ง‰์•„์ฃผ์ง€, **์„œ๋กœ ๋‹ค๋ฅธ ํ‚ค** ์ˆ˜์‹ญ ๊ฐœ๊ฐ€ ๋™์‹œ์— miss ๋‚˜๋Š” ๊ฑด ๋ชป ๋ง‰๋Š”๋‹ค. + +๊ทธ๋ฆฌ๊ณ  ์ข‹์•„์š” ๋นˆ๋„ ๋ฌธ์ œ: +``` +์ดˆ๋‹น ์ข‹์•„์š” 10๊ฑด ๋ฐœ์ƒ ์‹œ: +โ†’ ์ดˆ๋‹น 10๋ฒˆ ์ „์ฒด evict +โ†’ TTL 5๋ถ„์ด๋“  10๋ถ„์ด๋“  ์˜๋ฏธ ์—†์Œ +โ†’ ์บ์‹œ ํžˆํŠธ์œจ โ‰ˆ 0% +โ†’ Redis ์˜ค๋ฒ„ํ—ค๋“œ(์ง๋ ฌํ™”/๋„คํŠธ์›Œํฌ)๋งŒ ์ถ”๊ฐ€๋œ ์…ˆ +``` + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ๊ตฌํ˜„ ๋ณ€๊ฒฝ ์—†์Œ / ๋ฌดํšจํ™” ๋นˆ๋„๊ฐ€ ๋†’์œผ๋ฉด ์บ์‹œ ์ž์ฒด๊ฐ€ ๋ฌด์˜๋ฏธํ•ด์ง | +| ์ถ”์ฒœ ์ƒํ™ฉ | ๋ฌดํšจํ™” ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ **์ €๋นˆ๋„**(๊ด€๋ฆฌ์ž ์ž‘์—… ๋“ฑ)์ธ ๊ฒฝ์šฐ๋งŒ | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | **๋ถ€์ ํ•ฉ** โ€” ์ข‹์•„์š”(๊ณ ๋นˆ๋„)๊ฐ€ ๋งค๋ฒˆ ์ „์ฒด evict๋ฅผ ํŠธ๋ฆฌ๊ฑฐ | + +### 3.3 ์ „๋žต 3: TTL๋งŒ ์‚ฌ์šฉ (evict ์ œ๊ฑฐ) + +๋ณ€๊ฒฝ ์‹œ evict ์•ˆ ํ•˜๊ณ , TTL ์ž์—ฐ ๋งŒ๋ฃŒ์—๋งŒ ์˜์กด. +๊ฐ€์žฅ ๋‹จ์ˆœํ•˜๊ณ , ์˜์™ธ๋กœ ๋งŽ์€ ์ƒํ™ฉ์—์„œ ์ •๋‹ต์ด๋‹ค. + +``` +[์“ฐ๊ธฐ ๋ฐœ์ƒ] +โ†’ ์บ์‹œ evict ์•ˆ ํ•จ +โ†’ ๊ธฐ์กด ์บ์‹œ๋Š” TTL ๋งŒ๋ฃŒ๊นŒ์ง€ stale ์ƒํƒœ๋กœ ์„œ๋น™ +โ†’ ๋งŒ๋ฃŒ ํ›„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฐฑ์‹  +``` + +๋ฐ์ดํ„ฐ๋ณ„ stale ํ—ˆ์šฉ๋„: + +| ๋ฐ์ดํ„ฐ | 5๋ถ„ stale ํ—ˆ์šฉ? | ๊ทผ๊ฑฐ | +|--------|:--------------:|------| +| ์ข‹์•„์š” ์ˆ˜ | โœ… | "99์ข‹์•„์š”"์™€ "100์ข‹์•„์š”" ์ฐจ์ด๋Š” UX์— ๋ฌด๊ด€ | +| ๋ธŒ๋žœ๋“œ๋ช… | โœ… | ๊ด€๋ฆฌ์ž ์ €๋นˆ๋„ ๋ณ€๊ฒฝ, 5๋ถ„ ์ง€์—ฐ ๋ฌดํ•ด | +| ์ƒํ’ˆ๋ช…/๊ฐ€๊ฒฉ | โœ… | ๊ด€๋ฆฌ์ž ๋ณ€๊ฒฝ, ์ฆ‰์‹œ ๋ฐ˜์˜ ๋ถˆํ•„์š” | +| ์žฌ๊ณ  | โš ๏ธ | "ํ’ˆ์ ˆ์ธ๋ฐ ์žฌ๊ณ  ์žˆ์Œ ํ‘œ์‹œ" ๊ฐ€๋Šฅ โ€” ์ฃผ๋ฌธ ์‹œ ์„œ๋ฒ„ ๊ฒ€์ฆ์œผ๋กœ ๋ฐฉ์–ด ๊ฐ€๋Šฅ | +| ์‚ญ์ œ๋œ ์ƒํ’ˆ | โš ๏ธ | ์‚ญ์ œ ์ƒํ’ˆ์ด ๋ชฉ๋ก์— ๋…ธ์ถœ โ€” ํด๋ฆญ ์‹œ 404๋กœ ๋ฐฉ์–ด ๊ฐ€๋Šฅ | + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ๊ตฌํ˜„ ์ตœ๋‹จ์ˆœ / ์ตœ๋Œ€ TTL ์‹œ๊ฐ„๋งŒํผ stale ๋ฐ์ดํ„ฐ ๋…ธ์ถœ | +| ์ถ”์ฒœ ์ƒํ™ฉ | ์‹ค์‹œ๊ฐ„ ์ •ํ•ฉ์„ฑ์ด ๋ถˆํ•„์š”ํ•˜๊ณ , stale ์‹œ ์„œ๋ฒ„ ๊ฒ€์ฆ์œผ๋กœ ๋ฐฉ์–ด ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | **๋ชฉ๋ก ์บ์‹œ์— ์ ํ•ฉ** โ€” ์ข‹์•„์š”/์žฌ๊ณ  stale์€ UX์— ์น˜๋ช…์ ์ด์ง€ ์•Š๊ณ , ์ฃผ๋ฌธ/์ƒ์„ธ ์กฐํšŒ ์‹œ ์„œ๋ฒ„๊ฐ€ ์ตœ์‹  ๋ฐ์ดํ„ฐ๋กœ ๊ฒ€์ฆ | + +### 3.4 ์ „๋žต 4: ์—ญ์ธ๋ฑ์Šค ์ถ”์  โ†’ ES ๋„์ž… + +`brand:5:products โ†’ [1, 2, 9999]` ๊ฐ™์€ ์—ญ์ธ๋ฑ์Šค๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•˜์—ฌ ์„ ๋ณ„ evict. + +์ง์ ‘ ๊ตฌํ˜„ํ•˜๋ฉด: +``` +// ์ƒํ’ˆ ์ƒ์„ฑ ์‹œ +redis> SADD brand:5:products 1 +redis> SADD page:LATEST:0 1 + +// ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ +redis> SMEMBERS brand:5:products โ†’ [1, 2, 9999] +redis> DEL product:1 product:2 product:9999 +``` + +**์ง์ ‘ ๊ตฌํ˜„์˜ ๋ฌธ์ œ:** ์บ์‹œ๋ฅผ ๊ด€๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ ์บ์‹œ๊ฐ€ ํ•„์š”ํ•ด์ง€๊ณ , ์ด ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์˜ ์ •ํ•ฉ์„ฑ๋„ ๊ด€๋ฆฌํ•ด์•ผ ํ•œ๋‹ค. ์บ์‹œ ๊ด€๋ฆฌ ๋น„์šฉ์ด ์›๋ž˜ ๋ฌธ์ œ๋ณด๋‹ค ์ปค์ง„๋‹ค. + +ES ๋„์ž…์— ๋Œ€ํ•œ ์˜๊ฒฌ: ES๋Š” "์—ญ์ธ๋ฑ์Šค ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰ ์—”์ง„"์ด์ง€ "์บ์‹œ ์†”๋ฃจ์…˜"์ด ์•„๋‹ˆ๋‹ค. ๋„์ž…ํ•˜๋ฉด ๋ฌธ์ œ๊ฐ€ ๋ฐ”๋€๋‹ค: + +``` +[ํ˜„์žฌ] DB โ†’ Read Model โ†’ Redis Cache โ†’ ์‚ฌ์šฉ์ž +[ES] DB โ†’ Read Model โ†’ ES โ†’ ์‚ฌ์šฉ์ž +``` + +ES๋ฅผ ๋„์ž…ํ•˜๋ฉด ์บ์‹œ ๋ฌดํšจํ™” ๋ฌธ์ œ ๋Œ€์‹  **ES ๋™๊ธฐํ™” ๋ฌธ์ œ**๊ฐ€ ์ƒ๊ธด๋‹ค (๊ฐ™์€ ๋ณธ์งˆ). ๋‹ค๋งŒ ES๋Š” ๋™๊ธฐํ™”๋ฅผ ์œ„ํ•œ ์ƒํƒœ๊ณ„(Logstash, CDC ๋“ฑ)๊ฐ€ ์„ฑ์ˆ™ํ•ด์„œ, ๊ทœ๋ชจ๊ฐ€ ์ปค์ง€๋ฉด ํ•ฉ๋ฆฌ์  ์„ ํƒ์ด๋‹ค. + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ์ •๋ฐ€ ๋ฌดํšจํ™” ๊ฐ€๋Šฅ / ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ ๋ณต์žก๋„ ํญ์ฆ (์ง์ ‘ ๊ตฌํ˜„ ์‹œ), ์ธํ”„๋ผ ๋น„์šฉ (ES ์‹œ) | +| ์ถ”์ฒœ ์ƒํ™ฉ | ๊ฒ€์ƒ‰ ์š”๊ตฌ์‚ฌํ•ญ์ด ๋ณต์žกํ•˜๊ณ (์ „๋ฌธ๊ฒ€์ƒ‰, ์ž๋™์™„์„ฑ ๋“ฑ), ์ด๋ฏธ ES ์ธํ”„๋ผ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | **๊ณผ๋„ํ•จ** โ€” ๋ธŒ๋žœ๋“œ ํ•„ํ„ฐ + ์ •๋ ฌ ์ •๋„์˜ ์š”๊ตฌ์‚ฌํ•ญ์— ES๋Š” ๋Œ€ํฌ๋กœ ํŒŒ๋ฆฌ ์žก๊ธฐ | + +### 3.5 ์ „๋žต 5: ๋ชฉ๋ก ์บ์‹ฑ ์ œ๊ฑฐ + ๊ฐœ๋ณ„ ์ƒํ’ˆ๋งŒ ์บ์‹ฑ + +`products:list:*` ์บ์‹œ๋ฅผ ์•„์˜ˆ ์—†์• ๊ณ , `product:{id}`๋งŒ ์œ ์ง€. + +๊ตฌ์ฒด์  ์ˆ˜์น˜ ๋น„๊ต: + +``` +[ํ˜„์žฌ โ€” ๋ชฉ๋ก ์บ์‹œ ์žˆ์Œ] +๋ชฉ๋ก ์กฐํšŒ ์š”์ฒญ โ†’ Redis ์กฐํšŒ (1~2ms ๋„คํŠธ์›Œํฌ) โ†’ hit์ด๋ฉด ๋ฐ˜ํ™˜ + โ†’ miss์ด๋ฉด DB (0.85ms) + Redis ์ €์žฅ + +[์ œ์•ˆ โ€” ๋ชฉ๋ก ์บ์‹œ ์—†์Œ] +๋ชฉ๋ก ์กฐํšŒ ์š”์ฒญ โ†’ DB ์ง์ ‘ (0.85ms) โ†’ ๋ฐ˜ํ™˜ +``` + +๋ชฉ๋ก ์บ์‹œ๊ฐ€ ์ฃผ๋Š” ์‹ค์งˆ ์ด๋“: + +| ์‹œ๋‚˜๋ฆฌ์˜ค | ์บ์‹œ ์žˆ์Œ (hit) | ์บ์‹œ ์—†์Œ | ์ฐจ์ด | +|---------|:-:|:-:|:-:| +| ๋ชฉ๋ก ์กฐํšŒ | ~1.3ms (Redis) | ~0.85ms (DB) | **DB๊ฐ€ ๋” ๋น ๋ฆ„** | + +Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค ๋•๋ถ„์— **DB ์ฟผ๋ฆฌ๊ฐ€ ์ด๋ฏธ Redis ์‘๋‹ต๊ณผ ๋น„์Šทํ•˜๊ฑฐ๋‚˜ ๋” ๋น ๋ฅด๋‹ค.** ์บ์‹œ์˜ ์กด์žฌ ์˜์˜๊ฐ€ ์—†๋‹ค. + +๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ๋กœ ์‚ฌ๋ผ์ง€๋Š” ๋ฌธ์ œ๋“ค: + +| ๋ฌธ์ œ | ๋ชฉ๋ก ์บ์‹œ ์žˆ์„ ๋•Œ | ์ œ๊ฑฐ ํ›„ | +|------|:-:|:-:| +| ์ข‹์•„์š” ์‹œ ์ „์ฒด evict | ๋ฐœ์ƒ | ์ œ๊ฑฐ๋จ | +| ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ evict ๋ˆ„๋ฝ | ๋ฐœ์ƒ | ์ œ๊ฑฐ๋จ | +| ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ ์œ„ํ—˜ | ์กด์žฌ | ์ œ๊ฑฐ๋จ | +| evictByPattern SCAN ๋น„์šฉ | ์กด์žฌ | ์ œ๊ฑฐ๋จ | +| ์บ์‹œ ์ •ํ•ฉ์„ฑ ๊ด€๋ฆฌ ๋ถ€๋‹ด | ์กด์žฌ | ์ œ๊ฑฐ๋จ | + +์ƒ์„ธ ์บ์‹œ(`product:{id}`)๋Š” ์œ ์ง€ํ•˜๋Š” ์ด์œ : + +| ๊ธฐ์ค€ | ์ƒ์„ธ ์บ์‹œ | +|------|----------| +| Blast Radius | 1๊ฐœ ํ‚ค (์ •ํ™•ํ•œ ํƒ€๊ฒŸ evict ๊ฐ€๋Šฅ) | +| ๋ฌดํšจํ™” ๋นˆ๋„ | ํ•ด๋‹น ์ƒํ’ˆ ๋ณ€๊ฒฝ ์‹œ๋งŒ (์ €๋นˆ๋„) | +| Hit Rate | ์ธ๊ธฐ ์ƒํ’ˆ์€ ๋ฐ˜๋ณต ์กฐํšŒ โ†’ ๋†’์€ ํžˆํŠธ์œจ | + +"๋“๋ณด๋‹ค ์‹ค์ด ํฌ์ง€ ์•Š๋‚˜?"์— ๋Œ€ํ•œ ๋‹ต: +์‹ค(์žƒ๋Š” ๊ฒƒ)์ด ๊ฑฐ์˜ ์—†๋‹ค. DB ์ฟผ๋ฆฌ๊ฐ€ 0.85ms์ธ๋ฐ ์บ์‹œ ์‘๋‹ต์ด 1.3ms์ด๋ฉด, ์บ์‹œ๋ฅผ ์ œ๊ฑฐํ•˜๋Š” ๊ฒŒ ์˜คํžˆ๋ ค **์„ฑ๋Šฅ์ด ์ข‹์•„์ง„๋‹ค.** ์บ์‹œ๊ฐ€ ์ด๋“์„ ์ฃผ๋ ค๋ฉด Miss Cost๊ฐ€ ์ถฉ๋ถ„ํžˆ ๋†’์•„์•ผ ํ•˜๋Š”๋ฐ, Read Model์ด ๊ทธ ์ „์ œ๋ฅผ ๊นจ๋ฒ„๋ ธ๋‹ค. + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ๋ชจ๋“  ๋ฌดํšจํ™” ๋ฌธ์ œ ์ œ๊ฑฐ / DB ๋ถ€ํ•˜๊ฐ€ ์ง์ ‘ ๊ฑธ๋ฆผ (ํ•˜์ง€๋งŒ 0.85ms๋ฉด ๋ฌธ์ œ ์—†์Œ) | +| ์ถ”์ฒœ ์ƒํ™ฉ | DB ์ฟผ๋ฆฌ๊ฐ€ ์ถฉ๋ถ„ํžˆ ๋น ๋ฅด๊ณ , ์บ์‹œ ๋ฌดํšจํ™”๊ฐ€ ๋ณต์žกํ•œ ๊ฒฝ์šฐ | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | **๊ฐ€์žฅ ์ ํ•ฉ** โ€” Read Model์ด ์บ์‹œ์˜ ์กด์žฌ ์ด์œ ๋ฅผ ์ œ๊ฑฐํ•จ | + +### 3.6 ์ „๋žต 6: Cache Versioning (์„ธ๋Œ€ ๊ต์ฒด) + +``` +// ํ˜„์žฌ generation: 5 +cache key: products:list:v5:LATEST:0:20 + +// ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ generation ์ฆ๊ฐ€ +redis> INCR products:list:generation โ†’ 6 + +// ์ดํ›„ ์š”์ฒญ +cache key: products:list:v6:LATEST:0:20 โ†’ miss โ†’ DB ์กฐํšŒ โ†’ ์บ์‹œ ์ €์žฅ +// v5 ํ‚ค๋“ค์€ evict ์•ˆ ํ•จ โ†’ TTL๋กœ ์ž์—ฐ ๋งŒ๋ฃŒ +``` + +| ํ•ญ๋ชฉ | ํ‰๊ฐ€ | +|------|------| +| trade-off | ์Šคํƒฌํ”ผ๋“œ ์ œ๊ฑฐ / Redis ๋ฉ”๋ชจ๋ฆฌ ์ผ์‹œ์  2๋ฐฐ ์‚ฌ์šฉ | +| ์ถ”์ฒœ ์ƒํ™ฉ | ์ „์ฒด evict๊ฐ€ ๋ถˆ๊ฐ€ํ”ผํ•˜์ง€๋งŒ ์Šคํƒฌํ”ผ๋“œ๋ฅผ ํ”ผํ•ด์•ผ ํ•  ๋•Œ | +| ํ˜„์žฌ ํ”„๋กœ์ ํŠธ | 5๋ฒˆ ์ „๋žต์ด ๋” ๋‹จ์ˆœํ•˜๋ฏ€๋กœ ๊ตณ์ด ํ•„์š” ์—†์Œ | + +--- + +## 4. ์ „๋žต ์„ ํƒ ๊ณผ์ • ๋ฐ ๊ทผ๊ฑฐ + +### 4.1 1์ฐจ ์ข…ํ•ฉ ๋น„๊ต + +| # | ์ „๋žต | Hit Rate | ๋ณต์žก๋„ | ์Šคํƒฌํ”ผ๋“œ ์œ„ํ—˜ | ํ˜„์žฌ ์ ํ•ฉ๋„ | +|:-:|------|:--------:|:------:|:----------:|:---------:| +| 1 | ํ‚ค์— ํ•„๋“œ๊ฐ’ ํฌํ•จ | ๋†’์Œ | ๋†’์Œ | ๋‚ฎ์Œ | **๋ถ€์ ํ•ฉ** (์ •๋ ฌ+ํŽ˜์ด์ง€๋„ค์ด์…˜) | +| 2 | ์ „์ฒด evict + ๋ฐฉ์–ด ์‹ ๋ขฐ | ๋‚ฎ์Œ | ๋‚ฎ์Œ | **๋†’์Œ** | **๋ถ€์ ํ•ฉ** (๊ณ ๋นˆ๋„ ํŠธ๋ฆฌ๊ฑฐ) | +| 3 | TTL๋งŒ ์‚ฌ์šฉ | ๋†’์Œ | ์ตœ์ € | ์—†์Œ | ์ ํ•ฉ (stale ํ—ˆ์šฉ ์‹œ) | +| 4 | ์—ญ์ธ๋ฑ์Šค / ES | ๋†’์Œ | **์ตœ๊ณ ** | ๋‚ฎ์Œ | **๊ณผ๋„ํ•จ** | +| 5 | **๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ** | - | **์ตœ์ €** | **์—†์Œ** | **๊ฐ€์žฅ ์ ํ•ฉ** | +| 6 | Cache Versioning | ์ค‘๊ฐ„ | ์ค‘๊ฐ„ | ์—†์Œ | ๋ถˆํ•„์š” (5๋ฒˆ์ด ๋” ๋‹จ์ˆœ) | + +**1์ฐจ ๊ฒฐ๋ก :** ์ „๋žต 5 (๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ + ์ƒ์„ธ๋งŒ ์œ ์ง€)๊ฐ€ ๊ฐ€์žฅ ํ•ฉ๋ฆฌ์ ์ด๋‹ค. Read Model + ์ธ๋ฑ์Šค๊ฐ€ ๋ชฉ๋ก ์บ์‹œ์˜ ์กด์žฌ ์ด์œ ๋ฅผ ์ œ๊ฑฐํ–ˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. + +### 4.2 1์ฐจ ์„ ๋ณ„ โ€” ๋ช…ํ™•ํ•œ ์ œ์™ธ ๋Œ€์ƒ + +| # | ์ „๋žต | ํŒ์ • | ์ œ์™ธ ๊ทผ๊ฑฐ | +|:-:|------|:----:|----------| +| 1 | ํ‚ค์— ํ•„๋“œ๊ฐ’ ํฌํ•จ | **์ œ์™ธ** | ์ •๋ ฌ+ํŽ˜์ด์ง€๋„ค์ด์…˜์—์„œ ๊ทผ๋ณธ์ ์œผ๋กœ ์ž‘๋™ ๋ถˆ๊ฐ€ | +| 2 | ์ „์ฒด evict + ๋ฐฉ์–ด ์‹ ๋ขฐ | **์ œ์™ธ** | write๊ฐ€ ์žฆ์œผ๋ฉด ์ง€์†์  lock ๋ฐœ์ƒ. ์ด์  ๋Œ€๋น„ ๋‹จ์ ์ด ๊ณผ๋Œ€ | +| 4 | ์—ญ์ธ๋ฑ์Šค / ES | **์ œ์™ธ** | ๋ณต์žก๋„๊ฐ€ ์ตœ๊ณ  ์ˆ˜์ค€. ์‚ฌ์ด๋“œ์ดํŽ™ํŠธ ๋ฐ ๊ด€๋ฆฌํฌ์ธํŠธ ์ฆ๊ฐ€๋ฅผ ๊ฐ๋‹นํ•˜๊ธฐ ์–ด๋ ค์›€ | + +### 4.3 ์ „๋žต 5 ์žฌํ‰๊ฐ€ โ€” ํŠธ๋ž˜ํ”ฝ ๊ด€์ ์˜ ํ•œ๊ณ„ ๋ฐœ๊ฒฌ + +> **์„ ํƒ ๊ธฐ์ค€์˜ ๋ณ€ํ™”:** ์ดˆ๊ธฐ ๋ถ„์„์—์„œ๋Š” "๋‹จ๊ฑด ์ฟผ๋ฆฌ ์‘๋‹ต ์†๋„" ๊ธฐ์ค€์œผ๋กœ "DB ์ฟผ๋ฆฌ 0.85ms์ด๋ฉด ์บ์‹œ ๋ถˆํ•„์š”"๋ผ๊ณ  ํŒ๋‹จํ–ˆ์œผ๋‚˜, **ํŠธ๋ž˜ํ”ฝ ๋ณผ๋ฅจ ๊ด€์ ์ด ๋ˆ„๋ฝ**๋˜์—ˆ๋‹ค๋Š” ์‚ฌ์‹ค์„ ๋ฐœ๊ฒฌํ–ˆ๋‹ค. + +**PLP(Product List Page) vs PDP(Product Detail Page) ํŠธ๋ž˜ํ”ฝ ํŠน์„ฑ:** + +``` +์‚ฌ์šฉ์ž ํ–‰๋™ ํผ๋„: +๊ฒ€์ƒ‰/๋ชฉ๋ก(PLP) โ†’ ์ƒ์„ธ(PDP) โ†’ ์žฅ๋ฐ”๊ตฌ๋‹ˆ โ†’ ์ฃผ๋ฌธ + +ํŠธ๋ž˜ํ”ฝ ๋น„์œจ (์ผ๋ฐ˜์  ์ปค๋จธ์Šค): +PLP : PDP โ‰ˆ 10:1 ~ 100:1 +``` + +- PLP๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ€์žฅ ๋จผ์ €, ๊ฐ€์žฅ ์ž์ฃผ ์ ‘๊ทผํ•˜๋Š” ์ง„์ž…์  +- PDP๋Š” ๊ด€์‹ฌ ์ƒํ’ˆ๋งŒ ํด๋ฆญํ•˜๋ฏ€๋กœ ์ƒ๋Œ€์ ์œผ๋กœ ํŠธ๋ž˜ํ”ฝ์ด ์ ์Œ + +**๋‹จ๊ฑด ์ฟผ๋ฆฌ ์†๋„ vs ๋™์‹œ ์š”์ฒญ ์ˆ˜:** + +``` +PLP ์ดˆ๋‹น 10,000 ์š”์ฒญ ร— 0.85ms = DB ์ปค๋„ฅ์…˜ ํ’€ ๊ณ ๊ฐˆ ์œ„ํ—˜ +โ†’ ์บ์‹œ๊ฐ€ ์ด ๋ถ€ํ•˜๋ฅผ ํก์ˆ˜ํ•ด์ฃผ๋Š” ์—ญํ• ์ด ํ•ต์‹ฌ + +Redis๋Š” ๋‹จ์ผ ์Šค๋ ˆ๋“œ๋กœ๋„ 10๋งŒ+ QPS ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ +โ†’ "์‘๋‹ต ์†๋„"๊ฐ€ ์•„๋‹Œ "DB ๋ถ€ํ•˜ ์ฐจ๋‹จ"์ด ๋ชฉ๋ก ์บ์‹œ์˜ ์ง„์งœ ๊ฐ€์น˜ +``` + +**์žฅ๋ฐ”๊ตฌ๋‹ˆ์™€์˜ ๋น„๊ต:** +- ์žฅ๋ฐ”๊ตฌ๋‹ˆ: ์‹ค์‹œ๊ฐ„์„ฑ์ด ์ฆ‰๊ฐ์ ์œผ๋กœ ์ค‘์š” โ†’ ์บ์‹ฑ ๋ถ€์ ํ•ฉ +- ์ƒํ’ˆ ๋ชฉ๋ก: ์ข‹์•„์š” ์ˆ˜ 1~2๊ฐœ ์ฐจ์ด, ์žฌ๊ณ  ์•ฝ๊ฐ„์˜ ์ง€์—ฐ์€ ํ—ˆ์šฉ ๊ฐ€๋Šฅ โ†’ ์บ์‹ฑ ์ ํ•ฉ + +**๊ฒฐ๋ก :** ๋ชฉ๋ก ์บ์‹œ๋Š” ์œ ์ง€ํ•ด์•ผ ํ•œ๋‹ค. ๋ฌธ์ œ๋Š” "์บ์‹œ๋ฅผ ์“ธ ๊ฒƒ์ธ๊ฐ€"๊ฐ€ ์•„๋‹ˆ๋ผ "๋ฌดํšจํ™”๋ฅผ ์–ด๋–ป๊ฒŒ ํ•  ๊ฒƒ์ธ๊ฐ€"์ด๋‹ค. + +| # | ์ „๋žต | ํŒ์ • | +|:-:|------|:----:| +| 5 | ๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ | **์ œ์™ธ** โ€” PLP ํŠธ๋ž˜ํ”ฝ ์ง‘์ค‘ ํŠน์„ฑ์ƒ ์บ์‹œ ์œ ์ง€ ํ•„์š” | + +### 4.4 ์ „๋žต 6 ์„ธ๋ถ„ํ™” โ€” ์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹ vs ๋ฐ์ดํ„ฐ ์„ธ๋Œ€ ๊ต์ฒด + +> **์„ ํƒ ๊ธฐ์ค€์˜ ๋ณ€ํ™”:** ์ „๋žต 5๋ฅผ ์ œ์™ธํ•˜๋ฉด์„œ, ๋‚จ์€ ํ›„๋ณด๋Š” ์ „๋žต 3 (TTL)๊ณผ ์ „๋žต 6 (Cache Versioning)์ด์—ˆ๋‹ค. ์ „๋žต 6์„ ๋‹ค์‹œ ๋ถ„์„ํ•˜๋‹ˆ ํ•˜๋‚˜์˜ ์ด๋ฆ„ ์•„๋ž˜ **์„œ๋กœ ๋‹ค๋ฅธ ๋‘ ๊ฐ€์ง€ ๊ฐœ๋…**์ด ์„ž์—ฌ ์žˆ์—ˆ๋‹ค. + +| ๊ตฌ๋ถ„ | ๋ชฉ์  | ๋ฒ„์ „ ๋ณ€๊ฒฝ ์‹œ์  | ์˜ˆ์‹œ | +|------|------|-------------|------| +| **์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹** | DTO ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ ์—ญ์ง๋ ฌํ™” ํ˜ธํ™˜์„ฑ | ๋ฐฐํฌ ์‹œ (์ˆ˜๋™, ์ƒ์ˆ˜ ๋ณ€๊ฒฝ) | `product:v1:{id}` โ†’ `product:v2:{id}` | +| **๋ฐ์ดํ„ฐ ์„ธ๋Œ€ ๊ต์ฒด** | ์ „์ฒด evict ๋Œ€์‹  ์„ธ๋Œ€ ๋ฒˆํ˜ธ ์ฆ๊ฐ€๋กœ ๋ฌดํšจํ™” | ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์‹œ (์ž๋™, INCR) | `products:list:gen5:...` โ†’ `products:list:gen6:...` | + +**์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹ โ€” ํ™•์ • (eviction ์ „๋žต๊ณผ ๋…๋ฆฝ):** + +๋ฐฐํฌ ์•ˆ์ „์„ฑ์„ ์œ„ํ•œ ๊ธฐ๋ฐ˜ ์ธํ”„๋ผ. eviction ์ „๋žต๊ณผ ๋…๋ฆฝ์ ์œผ๋กœ ์ ์šฉํ•œ๋‹ค. + +์ ์šฉ ๊ทผ๊ฑฐ: +- ๋ฐฐํฌ ์‹œ DTO ๊ตฌ์กฐ ๋ณ€๊ฒฝ์ด ์žˆ์œผ๋ฉด, ๊ธฐ์กด Redis ์บ์‹œ์˜ ์—ญ์ง๋ ฌํ™”๊ฐ€ ์‹คํŒจํ•˜๊ฑฐ๋‚˜ ํ•„๋“œ ๋ˆ„๋ฝ ๋ฐœ์ƒ +- ๊ตฌํ˜„ ๋น„์šฉ ์ตœ์†Œ: `ProductCacheManager`์—์„œ ์บ์‹œ ํ‚ค์— `v1` ์ƒ์ˆ˜ ์ถ”๊ฐ€, ํ–ฅํ›„ DTO ๋ณ€๊ฒฝ ์‹œ ์ƒ์ˆ˜๋งŒ ์ฆ๊ฐ€ + +**๋ฐ์ดํ„ฐ ์„ธ๋Œ€ ๊ต์ฒด โ€” ๋ถˆํ•„์š”:** + +์ „๋žต 3 (TTL) + write-through ์กฐํ•ฉ์ด ๋” ๋‹จ์ˆœํ•˜๊ฒŒ ๊ฐ™์€ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•œ๋‹ค. ์„ธ๋Œ€ ๋ฒˆํ˜ธ ๊ด€๋ฆฌ, Redis ๋ฉ”๋ชจ๋ฆฌ 2๋ฐฐ ์‚ฌ์šฉ ๋“ฑ์˜ ๋ถ€๋‹ด์ด ์žˆ์œผ๋ฉด์„œ ์ด์ ์€ ์ œํ•œ์ ์ด๋‹ค. + +### 4.5 ์ตœ์ข… ์„ ํƒ + +> **์„ ํƒ ๊ธฐ์ค€์˜ ์ตœ์ข… ๋ณ€ํ™”:** "๋ชฉ๋ก ์บ์‹œ๋ฅผ ์—†์•จ ์ˆ˜ ์—†๋‹ค๋ฉด, ์บ์‹œ ๊ตฌ์กฐ ์ž์ฒด๋ฅผ ๋ฐ”๊ฟ”์•ผ ํ•œ๋‹ค"๋Š” ๊ฒฐ๋ก ์— ๋„๋‹ฌ. 1๊ณ„์ธต ์ „์ฒด DTO ์บ์‹ฑ์˜ ํ•œ๊ณ„๋ฅผ ์ธ์‹ํ•˜๊ณ , **2๊ณ„์ธต ๋ถ„๋ฆฌ (ID ๋ฆฌ์ŠคํŠธ + ์ƒํ’ˆ ์ƒ์„ธ)** ์•„ํ‚คํ…์ฒ˜๋ฅผ ๋„์ถœํ–ˆ๋‹ค. + +| ๊ฒฐ์ • | ๋‚ด์šฉ | +|------|------| +| **๋ชฉ๋ก ์บ์‹œ** | ์ „์ฒด DTO โ†’ **ID ๋ฆฌ์ŠคํŠธ๋งŒ ์บ์‹ฑ**์œผ๋กœ ์ „ํ™˜. ์ƒ์„ธ ๋ฐ์ดํ„ฐ๋Š” Layer 2์—์„œ MGET | +| **์ƒ์„ธ ์บ์‹œ** | **PLP + PDP ๊ณต์šฉ** ๋‹จ์ผ ์บ์‹œ. write-through๋กœ ์ฆ‰์‹œ ๊ฐฑ์‹  | +| **๋ฌดํšจํ™” ์ „๋žต** | `evictByPattern` **์ œ๊ฑฐ**. write-through (๊ฐฑ์‹ ) + TTL (์•ˆ์ „๋ง) ์กฐํ•ฉ | +| **์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹** | ์ ์šฉ. ์บ์‹œ ํ‚ค์— `v1` ์ƒ์ˆ˜ ํฌํ•จ | + +**2๊ณ„์ธต ๋ถ„๋ฆฌ๊ฐ€ ๊ทผ๋ณธ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ์ด์œ :** + +``` +[AS-IS โ€” 1๊ณ„์ธต] +products:list:all:LATEST:0:20 โ†’ [์ƒํ’ˆA ์ „์ฒด, ์ƒํ’ˆB ์ „์ฒด, ...] +โ†’ ์ƒํ’ˆA์˜ ์ข‹์•„์š” ๋ณ€๊ฒฝ โ†’ ์ด ์บ์‹œ ์ „์ฒด๊ฐ€ stale โ†’ evict ํ•„์š” + +[TO-BE โ€” 2๊ณ„์ธต] +Layer 1: products:ids:v1:all:LATEST:0:20 โ†’ [1, 5, 12] (ID๋งŒ) +Layer 2: product:v1:1 โ†’ {์ƒํ’ˆA ์ „์ฒด} (์ƒ์„ธ) +โ†’ ์ƒํ’ˆA์˜ ์ข‹์•„์š” ๋ณ€๊ฒฝ โ†’ product:v1:1๋งŒ write-through โ†’ Layer 1์€ ๋ฌด๊ด€ +``` + +- ID ๋ฆฌ์ŠคํŠธ์—๋Š” ์ข‹์•„์š”/์žฌ๊ณ /๋ธŒ๋žœ๋“œ๋ช… ๋“ฑ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ ํ•„๋“œ๊ฐ€ ์—†๋‹ค โ†’ ๋ฌดํšจํ™” ๋นˆ๋„ ๊ฒฉ๊ฐ +- ์ƒ์„ธ ์บ์‹œ๋Š” 1๊ฐœ ํ‚ค ๋‹จ์œ„๋กœ ์ •ํ™•ํžˆ ๊ฐฑ์‹  ๊ฐ€๋Šฅ โ†’ blast radius = 1 +- PLP์™€ PDP๊ฐ€ ๊ฐ™์€ ์ƒ์„ธ ์บ์‹œ๋ฅผ ๊ณต์œ  โ†’ ์บ์‹œ ํšจ์œจ ๊ทน๋Œ€ํ™” + +--- + +## 5. ์ตœ์ข… ์บ์‹œ ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„ (ํ™•์ •) + +### 5.1 ์บ์‹œ ๊ตฌ์กฐ โ€” 2๊ณ„์ธต ๋ถ„๋ฆฌ + +``` +Layer 1: ID ๋ฆฌ์ŠคํŠธ (PLP์šฉ) + products:ids:v1:{brandId|all}:{sortType}:{page}:{size} + โ†’ { ids: [1, 5, 12, ...], totalElements: 523 } + +Layer 2: ์ƒํ’ˆ ์ƒ์„ธ (PDP + PLP ๊ณต์šฉ) + product:v1:{productId} + โ†’ ProductCacheDto (์ „์ฒด ํ•„๋“œ ํฌํ•จ, API๋ณ„ ํ•„์š”ํ•œ ๊ฒƒ๋งŒ ์ถ”์ถœ) +``` + +### 5.2 ์บ์‹ฑ ๋ฒ”์œ„ + +| ํ•ญ๋ชฉ | ๋ฒ”์œ„ | +|------|------| +| **ID ๋ฆฌ์ŠคํŠธ** | ๋ชจ๋“  ํ•„ํ„ฐ ์กฐํ•ฉ (all + ๊ฐ brandId) ร— 3์ •๋ ฌ ร— pages 0~2 | +| **์ƒํ’ˆ ์ƒ์„ธ** | ๊ฐœ๋ณ„ ์ƒํ’ˆ ๋‹จ์œ„, API ๊ฐ„ ์žฌํ™œ์šฉ | +| **๊ด€๋ฆฌ์ž API** | ์บ์‹œ ๋ฏธ์ ์šฉ (์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ํ•„์š”, ํŠธ๋ž˜ํ”ฝ ์ ์Œ) | + +ํ•„ํ„ฐ ์กฐ๊ฑด: ํ˜„์žฌ `brandId` ํ•˜๋‚˜๋งŒ ์กด์žฌ (ํ‚ค์›Œ๋“œ/๊ฐ€๊ฒฉ ๋ฒ”์œ„/์นดํ…Œ๊ณ ๋ฆฌ ํ•„ํ„ฐ ์—†์Œ). + +**์บ์‹œ ์ ์šฉ ์กฐ๊ฑด (๊ฐ€๋“œ):** `page <= 2 && size == DEFAULT_PAGE_SIZE`์ผ ๋•Œ๋งŒ ID ๋ฆฌ์ŠคํŠธ๋ฅผ ์บ์‹œํ•œ๋‹ค. ์ด ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚˜๋Š” ์š”์ฒญ์€ cache-aside๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š๊ณ  DB ์ง์ ‘ ์กฐํšŒํ•œ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด write-through ๊ฐฑ์‹  ๋ฒ”์œ„์™€ ์บ์‹œ ์ ์žฌ ๋ฒ”์œ„๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜์—ฌ stale ์บ์‹œ๊ฐ€ ๋‚จ์ง€ ์•Š๋Š”๋‹ค. + +### 5.3 TTL + +| ์บ์‹œ | TTL | ๊ทผ๊ฑฐ | +|------|-----|------| +| ID ๋ฆฌ์ŠคํŠธ (PLP) | 3๋ถ„ | PLP๋Š” stale ํ—ˆ์šฉ๋„ ๋†’์Œ. write-through๊ฐ€ ์ฃผ๋ ฅ, TTL์€ ์•ˆ์ „๋ง | +| ์ƒํ’ˆ ์ƒ์„ธ (PDP) | 2๋ถ„ | ๊ตฌ๋งค ๊ฒฐ์ • ๋‹จ๊ณ„ โ†’ ์‹ค์‹œ๊ฐ„์„ฑ ๋” ์ค‘์š”. ๋” ์งง์€ ์•ˆ์ „๋ง | + +**TTL ์„ค์ • ๊ทผ๊ฑฐ โ€” write-through ๊ธฐ๋ฐ˜ freshness ๋ชจ๋ธ:** + +๋ณธ ์‹œ์Šคํ…œ์€ **write-through๊ฐ€ ์บ์‹œ freshness์˜ ์ฃผ ๋ฉ”์ปค๋‹ˆ์ฆ˜**์ด๋‹ค. ์ƒํ’ˆ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ ์‹œ Facade์—์„œ DB ์“ฐ๊ธฐ ์งํ›„ ์บ์‹œ๋ฅผ ์ฆ‰์‹œ ๊ฐฑ์‹ (๋˜๋Š” ์‚ญ์ œ)ํ•˜๋ฏ€๋กœ, ์ •์ƒ ๋™์ž‘ ์‹œ ์บ์‹œ๋Š” ํ•ญ์ƒ ์ตœ์‹  ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•œ๋‹ค. + +๋”ฐ๋ผ์„œ TTL์˜ ์—ญํ• ์€ **"๋ฐ์ดํ„ฐ๊ฐ€ ์–ผ๋งˆ๋‚˜ ๋นจ๋ฆฌ ๋ฐ˜์˜๋˜์–ด์•ผ ํ•˜๋Š”๊ฐ€"๊ฐ€ ์•„๋‹ˆ๋ผ, "write-through๊ฐ€ ์‹คํŒจํ–ˆ์„ ๋•Œ stale ๋ฐ์ดํ„ฐ๋ฅผ ์ตœ๋Œ€ ์–ผ๋งˆ๋‚˜ ํ—ˆ์šฉํ•  ์ˆ˜ ์žˆ๋Š”๊ฐ€"**์ด๋‹ค. write-through ์‹คํŒจ๋Š” Redis ์ผ์‹œ ์žฅ์• , ๋„คํŠธ์›Œํฌ ํŒŒํ‹ฐ์…˜ ๋“ฑ ์˜ˆ์™ธ์  ์ƒํ™ฉ์—์„œ๋งŒ ๋ฐœ์ƒํ•˜๋ฏ€๋กœ, TTL์€ ์ด ์˜ˆ์™ธ ์ƒํ™ฉ์˜ **์ตœ๋Œ€ staleness ์œˆ๋„์šฐ(์•ˆ์ „๋ง)**๋กœ ๊ธฐ๋Šฅํ•œ๋‹ค. + +**๋” ์งง์€ TTL(์˜ˆ: 30์ดˆ/1๋ถ„)์„ ์ฑ„ํƒํ•˜์ง€ ์•Š๋Š” ์ด์œ :** +- write-through๊ฐ€ ์ •์ƒ ๋™์ž‘ํ•˜๋Š” ํ•œ TTL ๊ธธ์ด์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ freshness๋Š” ๋™์ผ (near-real-time) +- TTL์„ ์ค„์ด๋ฉด cache miss ๋นˆ๋„๋งŒ ์ฆ๊ฐ€ํ•˜์—ฌ **long-tail ์ƒํ’ˆ์˜ hit rate๊ฐ€ ํ•˜๋ฝ**ํ•˜๊ณ  DB ๋ถ€ํ•˜๊ฐ€ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ์ƒ์Šน +- ์ฒด๊ฐ freshness ํ–ฅ์ƒ ์—†์ด ์บ์‹œ ํšจ์œจ๋งŒ ์ €ํ•˜๋˜๋Š” trade-off + +### 5.4 ์บ์‹œ ๊ฐฑ์‹  ์ •์ฑ… + +| ์ƒํ™ฉ | ์ •์ฑ… | ์„ค๋ช… | +|------|------|------| +| **์ฝ๊ธฐ ์‹œ ์บ์‹œ miss** | cache-aside | DB ์กฐํšŒ โ†’ ์บ์‹œ ์ ์žฌ (์ดˆ๊ธฐ ์ ์žฌ/cold start ๋Œ€์‘) | +| **์“ฐ๊ธฐ ์‹œ** | write-through | DB ์—…๋ฐ์ดํŠธ โ†’ ๊ฐ™์€ TX ๋‚ด์—์„œ ์บ์‹œ ๋ฎ์–ด์“ฐ๊ธฐ (best-effort โ€” TX ๋กค๋ฐฑ ์‹œ TTL ์•ˆ์ „๋ง ์˜์กด) | +| **evict** | ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ | `evictByPattern` ์ œ๊ฑฐ. ์‚ญ์ œ ๋Œ€์‹  ๊ฐฑ์‹ ์œผ๋กœ ์บ์‹œ warm ์œ ์ง€. **์˜ˆ์™ธ: ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์ƒ์„ธ ์บ์‹œ๋Š” explicit evict** | + +> **TODO**: ํ–ฅํ›„ ์ด๋ฒคํŠธ ๋„์ž… ์‹œ `@TransactionalEventListener(AFTER_COMMIT)` ๊ธฐ๋ฐ˜์œผ๋กœ ์ „ํ™˜ํ•˜์—ฌ TX ๋กค๋ฐฑ ์‹œ ์บ์‹œ ์ •ํ•ฉ์„ฑ ๋ณด์žฅ. ํ˜„์žฌ๋Š” TX ๋‚ด ์ฒ˜๋ฆฌ (๋กค๋ฐฑ ์‹œ TTL ์•ˆ์ „๋ง ์˜์กด). โ†’ `docs/todo/cache-event-driven-refresh.md` ์ฐธ์กฐ + +> **TODO**: `ApplicationReadyEvent` ๊ธฐ๋ฐ˜ ์บ์‹œ ์›œ์—… ๊ตฌํ˜„. ์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ hot ํŽ˜์ด์ง€๋ฅผ ์„ ์ œ์ ์œผ๋กœ ์บ์‹œ ์ ์žฌ. โ†’ `docs/todo/cache-event-driven-refresh.md` ์ฐธ์กฐ + +### 5.5 ์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹ + +์บ์‹œ ํ‚ค์— `v1` ์ƒ์ˆ˜ ํฌํ•จ. DTO ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ ์ƒ์ˆ˜๋งŒ ์ฆ๊ฐ€ํ•˜์—ฌ ๋ฐฐํฌ ์•ˆ์ „์„ฑ ํ™•๋ณด. + +``` +product:v1:{productId} +products:ids:v1:{brandId|all}:{sortType}:{page}:{size} +``` + +### 5.6 MGET partial miss ์ฒ˜๋ฆฌ + +``` +1. ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ์—์„œ IDs ์กฐํšŒ: [1, 5, 12, 7] +2. MGET product:v1:1 product:v1:5 product:v1:12 product:v1:7 +3. miss๋œ ID ์ถ”์ถœ: [5, 7] +4. DB์—์„œ miss๋ถ„๋งŒ ์กฐํšŒ +5. ์บ์‹œ์— ์ ์žฌ (cache-aside) +6. ํ•ฉ์ณ์„œ ID ๋ฆฌ์ŠคํŠธ ์ˆœ์„œ๋Œ€๋กœ ๋ฐ˜ํ™˜ +``` + +**dangling ID ๋ฐฉ์–ด:** MGET ๊ฒฐ๊ณผ์— null์ด ํฌํ•จ๋˜๊ณ  DB์—์„œ๋„ ์กฐํšŒ๋˜์ง€ ์•Š๋Š” ID(์‚ญ์ œ๋œ ์ƒํ’ˆ)๋Š” ์‘๋‹ต์—์„œ ์ œ์™ธํ•œ๋‹ค. write-through๊ฐ€ ์‚ญ์ œ ์‹œ ID ๋ฆฌ์ŠคํŠธ๋„ ๊ฐฑ์‹ ํ•˜๋ฏ€๋กœ ์ด ์ƒํ™ฉ์˜ window๋Š” ๊ทนํžˆ ์งง์ง€๋งŒ, ๋ฐฉ์–ด์ ์œผ๋กœ null skip ์ฒ˜๋ฆฌํ•œ๋‹ค. ์‘๋‹ต ๊ฑด์ˆ˜๊ฐ€ ์š”์ฒญ `size`๋ณด๋‹ค ์ ์„ ์ˆ˜ ์žˆ์œผ๋‚˜, ๋‹ค์Œ write-through์—์„œ ID ๋ฆฌ์ŠคํŠธ๊ฐ€ ์ •์ƒํ™”๋œ๋‹ค. + +### 5.7 write-through ํŠธ๋ฆฌ๊ฑฐ ๋งคํ•‘ + +| ๋ณ€๊ฒฝ ์ž‘์—… | ๋นˆ๋„ | ID ๋ฆฌ์ŠคํŠธ ๊ฐฑ์‹  ๋Œ€์ƒ | ์ƒ์„ธ ์บ์‹œ ๊ฐฑ์‹  | +|----------|:----:|-------------------|:----------:| +| ์ข‹์•„์š” ์ฆ๊ฐ€/๊ฐ์†Œ | ๋†’์Œ | `LIKES_DESC` ร— (all + brandId) ร— 3p = 6๊ฐœ | ํ•ด๋‹น ์ƒํ’ˆ 1๊ฐœ | +| ์žฌ๊ณ  ์ฐจ๊ฐ | ์ค‘๊ฐ„ | ์—†์Œ (์ •๋ ฌ ๋ฌด๊ด€) | ํ•ด๋‹น ์ƒํ’ˆ 1๊ฐœ | +| ์ƒํ’ˆ ์ˆ˜์ • (๊ฐ€๊ฒฉ) | ๋‚ฎ์Œ | `PRICE_ASC` ร— (all + brandId) ร— 3p = 6๊ฐœ | ํ•ด๋‹น ์ƒํ’ˆ 1๊ฐœ | +| ์ƒํ’ˆ ์ƒ์„ฑ | ๋‚ฎ์Œ | ๋ชจ๋“  ์ •๋ ฌ ร— (all + brandId) ร— 3p = 18๊ฐœ | ์‹ ๊ทœ ์ƒํ’ˆ 1๊ฐœ | +| ์ƒํ’ˆ ์‚ญ์ œ | ๋‚ฎ์Œ | ๋ชจ๋“  ์ •๋ ฌ ร— (all + brandId) ร— 3p = 18๊ฐœ | ํ•ด๋‹น ์ƒํ’ˆ ์‚ญ์ œ | +| ๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ • | ๊ทนํžˆ ๋‚ฎ์Œ | ์—†์Œ (ID์— brandName ๋ฏธํฌํ•จ) | ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์ „์ฒด ์ƒํ’ˆ | + +> ๋ธŒ๋žœ๋“œ๋ช… ์ˆ˜์ •์€ ๊ทนํžˆ ๋“œ๋ฌธ ์ž‘์—…์ด์ง€๋งŒ, TTL ์ž์—ฐ ๋งŒ๋ฃŒ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š๊ณ  write-through๋กœ ์ฆ‰์‹œ ๊ฐฑ์‹ ํ•œ๋‹ค. +> evict๊ฐ€ ์•„๋‹Œ write-through์ด๋ฏ€๋กœ ์บ์‹œ๊ฐ€ ํ•ญ์ƒ warm ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•˜๋ฉฐ, ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. +> ๊ตฌํ˜„ ํ๋ฆ„: `BrandCommandFacade.updateBrand()` โ†’ Read Model `brandName` ์ผ๊ด„ ๋™๊ธฐํ™” โ†’ ํ•ด๋‹น brandId ์ƒํ’ˆ ID ์กฐํšŒ โ†’ ๊ฐ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ write-through. + +> **์ „์ œ**: ์ƒํ’ˆ ์ˆ˜์ • ์‹œ Read Model `createdAt` ๋ณด์กด ํ•„์š”. ํ˜„์žฌ ๊ตฌํ˜„์€ `ProductReadModelRepositoryImpl.save()`๊ฐ€ ๊ธฐ์กด row๋ฅผ partial updateํ•˜์—ฌ `createdAt`๊ณผ `likeCount`๋ฅผ ๋ณด์กดํ•œ๋‹ค. + +### 5.8 Race Condition ๋ถ„์„ + +#### ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ (PDP) โ€” ๋™์ผ ํƒ€์ž… mutation์€ ์ง๋ ฌํ™”, ํ˜ผํ•ฉ mutation์€ ์ œํ•œ์  race ์กด์žฌ + +**๋™์ผ ํƒ€์ž… mutation (์˜ˆ: ์ข‹์•„์š” + ์ข‹์•„์š”):** + +| ์ž‘์—… | ์ž ๊ธˆ ๋ฐฉ์‹ | ๊ฒฐ๊ณผ | +|------|----------|------| +| `increaseLikeCount` | atomic UPDATE (row lock) | ์ง๋ ฌํ™”๋จ | +| `decreaseLikeCount` | atomic UPDATE (row lock) | ์ง๋ ฌํ™”๋จ | +| `decreaseStock` | `findByIdForUpdate` (PESSIMISTIC_WRITE) | ์ง๋ ฌํ™”๋จ | +| `updateProduct` | JPA merge โ†’ UPDATE (row lock) | ์ง๋ ฌํ™”๋จ | +| `deleteProduct` | DELETE (row lock) | ์ง๋ ฌํ™”๋จ | + +**ํ˜ผํ•ฉ mutation (์˜ˆ: admin update + user like ๋™์‹œ) โ€” ์บ์‹œ๊ฐ€ ์•„๋‹Œ ๋ฐ์ดํ„ฐ ๋ ˆ์ด์–ด ์ด์Šˆ:** + +`updateProduct`๋Š” JPA merge๋กœ ์ „์ฒด ํ•„๋“œ๋ฅผ ๋ฎ์–ด์“ฐ๋ฏ€๋กœ, ๋™์‹œ์— ์‹คํ–‰๋œ atomic `increaseLikeCount`์˜ ๊ฒฐ๊ณผ๊ฐ€ ์œ ์‹ค๋  ์ˆ˜ ์žˆ๋‹ค. ์ด๋Š” ์บ์‹œ ์„ค๊ณ„์™€ ๋ฌด๊ด€ํ•œ ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋ ˆ์ด์–ด์˜ lost update ๋ฌธ์ œ์ด๋ฉฐ, ์บ์‹œ๋Š” DB ์ปค๋ฐ‹ ํ›„ ์ƒํƒœ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฐ˜์˜ํ•œ๋‹ค. ๊ด€๋ฆฌ์ž ์ƒํ’ˆ ์ˆ˜์ •์€ ๊ทนํžˆ ์ €๋นˆ๋„์ด๋ฏ€๋กœ ์‹ค์งˆ์  ์˜ํ–ฅ์€ ๋ฌด์‹œ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ํ•„์š” ์‹œ ๊ด€๋ฆฌ์ž ์ˆ˜์ • ๊ฒฝ๋กœ์— ๋‚™๊ด€์  ๋ฝ(`@Version`) ์ ์šฉ์œผ๋กœ ํ•ด๊ฒฐ ๊ฐ€๋Šฅํ•˜๋‹ค. + +**๊ฒฐ๋ก :** ์บ์‹œ ๊ด€์ ์—์„œ์˜ race condition์€ DB ์ƒํƒœ์— ์ข…์†๋˜๋ฉฐ, ์บ์‹œ ์ž์ฒด๊ฐ€ ์ถ”๊ฐ€ ๋ถˆ์ผ์น˜๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค. + +#### ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ (PLP) โ€” Race ์žˆ์œผ๋‚˜ ๋ฌดํ•ด + +**๋‹ค๋ฅธ ์ƒํ’ˆ์— ๋Œ€ํ•œ ๋™์‹œ ๋ณ€๊ฒฝ**์€ ์„œ๋กœ ๋‹ค๋ฅธ row์ด๋ฏ€๋กœ DB lock ์ถฉ๋Œ์ด ์—†๋‹ค: + +``` +์ƒํ’ˆX(brandId=5) ์ข‹์•„์š” +1 โ†’ Thread A (row X lock) +์ƒํ’ˆY(brandId=5) ์ข‹์•„์š” +1 โ†’ Thread B (row Y lock) +โ†’ ๋ณ‘๋ ฌ ์‹คํ–‰ โ†’ ๋‘˜ ๋‹ค ๊ฐ™์€ ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ ํ‚ค์— write-through +โ†’ ๋งˆ์ง€๋ง‰์— ์“ด ์ชฝ์ด ์ด์ „ ์“ด ์ชฝ์„ ๋ฎ์–ด์”€ +``` + +| ์‹œ๋‚˜๋ฆฌ์˜ค | ์บ์‹œ ํ‚ค ์ถฉ๋Œ | ๋นˆ๋„ | ๋ถˆ์ผ์น˜ ๋‚ด์šฉ | ์ž๋™ ๋ณต๊ตฌ | +|---------|:-----------:|:----:|-----------|:--------:| +| ๊ฐ™์€ ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋™์‹œ ์ข‹์•„์š” | brand + all ํ•„ํ„ฐ | ์ค‘๊ฐ„ | ์ข‹์•„์š” 1~2๊ฐœ ์ฐจ์ด | ๋‹ค์Œ write-through | +| ๋‹ค๋ฅธ ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋™์‹œ ์ข‹์•„์š” | all ํ•„ํ„ฐ๋งŒ | ๋†’์Œ | ์ข‹์•„์š” 1~2๊ฐœ ์ฐจ์ด | ๋‹ค์Œ write-through | +| ์ƒ์„ฑ + ๋‹ค๋ฅธ ์ƒํ’ˆ ์ข‹์•„์š” | ์ •๋ ฌ๋ณ„ | ๋งค์šฐ ๋‚ฎ์Œ | ์‹ ๊ทœ ์ƒํ’ˆ ๋…ธ์ถœ 1~2์ดˆ ์ง€์—ฐ | ๋‹ค์Œ write-through | +| ์‚ญ์ œ + ๋‹ค๋ฅธ ์ƒํ’ˆ ์ข‹์•„์š” | ์ •๋ ฌ๋ณ„ | ๋งค์šฐ ๋‚ฎ์Œ | ์‚ญ์ œ ์ƒํ’ˆ 1~2์ดˆ ์ž”์กด | TTL (3๋ถ„) | + +**๊ฒฐ๋ก :** PLP ID ๋ฆฌ์ŠคํŠธ race condition์€ ๋น„์ฆˆ๋‹ˆ์Šค์ ์œผ๋กœ ๋ฌดํ•ดํ•˜๋‹ค. ์ข‹์•„์š” 1~2๊ฐœ ์ฐจ์ด๋Š” ์‚ฌ์šฉ์ž ์ธ์ง€ ๋ถˆ๊ฐ€๋Šฅํ•˜๊ณ , ๋‹ค์Œ write-through์—์„œ ์ˆ˜ ์ดˆ ๋‚ด ์ž๋™ ๋ณต๊ตฌ๋œ๋‹ค. + +### 5.9 ์„ค๊ณ„ ์ „์ œ ์กฐ๊ฑด + +| ์ „์ œ | ๊ทผ๊ฑฐ | +|------|------| +| ํ’ˆ์ ˆ ์ƒํ’ˆ(stock=0)์€ ๋ชฉ๋ก์—์„œ ์ œ์™ธํ•˜์ง€ ์•Š์Œ | ํ’ˆ์ ˆ ๊ฒ€์ฆ์€ ์žฅ๋ฐ”๊ตฌ๋‹ˆ/์ฃผ๋ฌธ ๋‹จ๊ณ„์—์„œ ์„œ๋ฒ„๊ฐ€ ์ˆ˜ํ–‰. stock์€ ์ •๋ ฌ/ํ•„ํ„ฐ ์กฐ๊ฑด์— ํฌํ•จ๋˜์ง€ ์•Š์Œ | +| ๋ธŒ๋žœ๋“œ visibleStatus ๋ณ€๊ฒฝ์€ ์ƒํ’ˆ ์บ์‹œ์— ์˜ํ–ฅ ์—†์Œ | ์‚ฌ์šฉ์ž ์ƒํ’ˆ ์กฐํšŒ๋Š” ๋ธŒ๋žœ๋“œ visibility๋ฅผ ๋ณด์ง€ ์•Š์Œ (`deletedAt IS NULL` + optional `brandId`๋งŒ ํ•„ํ„ฐ) | +| ์ƒํ’ˆ ๋ณต์› ๊ธฐ๋Šฅ ์—†์Œ | `SoftDeleteBaseEntity.restore()`๋Š” ์กด์žฌํ•˜์ง€๋งŒ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ณ„์ธต์—์„œ ๋ฏธ์‚ฌ์šฉ | +| ์ •๋ ฌ ๋ณด์กฐ ํ‚ค๋กœ `id` ์‚ฌ์šฉ | tie-breaker ์—†์œผ๋ฉด ๋™๋ฅ  ์‹œ ํŽ˜์ด์ง€ ๊ฒฝ๊ณ„ ๋ถˆ์•ˆ์ •. ๊ตฌํ˜„ ์‹œ `ORDER BY sort_col, id` ์ ์šฉ | + +> ์œ„ ์ „์ œ๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด ์บ์‹œ ํŠธ๋ฆฌ๊ฑฐ ๋งคํ•‘์„ ์žฌ๊ฒ€ํ† ํ•ด์•ผ ํ•œ๋‹ค. + +--- + +## 6. ์‹ค์ œ ์ ์šฉ ๊ฒฐ๊ณผ + +### 6.1 DB ์„ฑ๋Šฅ ๊ฐœ์„  โ€” Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค + +> ์ƒ์„ธ ๋ฐ์ดํ„ฐ: [`03-as-is-performance-measurement.md`](./03-as-is-performance-measurement.md), [`04-to-be-index-measurement.md`](./04-to-be-index-measurement.md) + +์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต์˜ ์ „์ œ๊ฐ€ ๋œ DB ์„ฑ๋Šฅ ๊ฐœ์„  ๊ฒฐ๊ณผ: + +**๋‹จ๊ฑด ์ฟผ๋ฆฌ ์„ฑ๋Šฅ (DB ๋ ˆ๋ฒจ):** + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | AS-IS (PK๋งŒ, Full Table Scan) | TO-BE (Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค) | ๊ฐœ์„  ๋ฐฐ์œจ | +|:----------:|:---:|:---:|:---:| +| 100K | 20.8~33.4ms | 0.74~6.96ms | **3~29x** | +| 1M | 408~585ms | 0.85~2.44ms | **240~545x** | +| 10M | 3,489~4,184ms | 2.29~8.20ms | **441~1,533x** | + +**Burst ํ…Œ์ŠคํŠธ (100 ๋™์‹œ ์Šค๋ ˆ๋“œ):** + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | AS-IS ์—๋Ÿฌ์œจ | TO-BE ์—๋Ÿฌ์œจ | AS-IS avg | TO-BE avg | +|:----------:|:---:|:---:|:---:|:---:| +| 100K | 0% | 0% | 365~494ms | 25~74ms | +| 1M | 70~80% | **0%** | 2,407~2,793ms | 20~35ms | +| 10M | 90% | **0%** | 10,591~14,824ms | 23~41ms | + +**ํ•ต์‹ฌ:** Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค๋กœ ๋ชฉ๋ก ์ฟผ๋ฆฌ๊ฐ€ **0.85ms** ์ˆ˜์ค€์œผ๋กœ ์•ˆ์ •ํ™”๋˜์—ˆ๋‹ค. ์ด ์ˆ˜์น˜๊ฐ€ "๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ ๊ฐ€๋Šฅ์„ฑ"(์ „๋žต 5)์„ ๊ฒ€ํ† ํ•˜๊ฒŒ ๋œ ์ง์ ‘์  ๊ณ„๊ธฐ์ด๋ฉฐ, ๋™์‹œ์— "์บ์‹œ miss ์‹œ DB fallback ๋น„์šฉ์ด ๋‚ฎ๋‹ค"๋Š” ์„ค๊ณ„ ์ „์ œ์˜ ๊ทผ๊ฑฐ์ด๊ธฐ๋„ ํ•˜๋‹ค. + +### 6.2 ์บ์‹œ ์ ์šฉ ํ›„ ์„ฑ๋Šฅ + +> ์ƒ์„ธ ๋ฐ์ดํ„ฐ: [`05-to-be-cache-measurement.md`](./05-to-be-cache-measurement.md) +> +> 2๊ณ„์ธต ์บ์‹œ ์•„ํ‚คํ…์ฒ˜(ID ๋ฆฌ์ŠคํŠธ + MGET ์ƒ์„ธ) + write-through ๊ธฐ๋ฐ˜ ์ธก์ • ๊ฒฐ๊ณผ. + +**Cache Hit ์‹œ ์‘๋‹ต (๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ๋ฌด๊ด€):** + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | Cache Hit ์‘๋‹ต | DB ์ง์ ‘ ์กฐํšŒ | ๋น„๊ณ  | +|:----------:|:---:|:---:|------| +| 100K | 5.14~7.41ms | 0.74~6.96ms | Redis โ‰ˆ DB | +| 1M | 4.07~5.24ms | 0.85~2.44ms | Redis > DB (๋‹จ๊ฑด ๊ธฐ์ค€) | + +**Sustained Load (20 RPS ร— 10์ดˆ, Cache Hit):** + +| ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ | ๋‹ฌ์„ฑ QPS | ์—๋Ÿฌ์œจ | ํ‰๊ท  ์‘๋‹ต | +|:----------:|:---:|:---:|:---:| +| 100K | 20.0 | 0% | 6.08~10.35ms | +| 1M | 20.0 | 0% | 6.50~9.07ms | + +**AS-IS ๋Œ€๋น„ ์ตœ์ข… ๊ฐœ์„  (1M ๊ธฐ์ค€, Cache Hit):** + +| ์ง€ํ‘œ | AS-IS | TO-BE | ๊ฐœ์„  | +|------|:---:|:---:|:---:| +| ๋‹จ๊ฑด ๋ชฉ๋ก ์‘๋‹ต | 482~516ms | ~4~5ms | **92~123x** | +| Burst ์—๋Ÿฌ์œจ | 71~80% | 0% | **์™„์ „ ํ•ด์†Œ** | +| Sustained QPS (20 RPS) | 3.7~5.9 | 20.0 | **3.4~5.4x** | +| Sustained ์—๋Ÿฌ์œจ | 37~60% | 0% | **์™„์ „ ํ•ด์†Œ** | + +### 6.3 ์บ์‹œ ๋ฌดํšจํ™” ์ „๋žต ๊ฐœ์„  ํšจ๊ณผ + +2๊ณ„์ธต ๋ถ„๋ฆฌ + write-through ์ „ํ™˜์œผ๋กœ ์ธํ•œ ๊ตฌ์กฐ์  ๊ฐœ์„ : + +| ํ•ญ๋ชฉ | AS-IS (evictByPattern) | TO-BE (2๊ณ„์ธต + write-through) | +|------|---|---| +| ์ข‹์•„์š” 1๊ฑด๋‹น ์˜ํ–ฅ | **์ „์ฒด ๋ชฉ๋ก ์บ์‹œ ์‚ญ์ œ** (์ˆ˜์‹ญ~์ˆ˜๋ฐฑ ํ‚ค) | ์ƒ์„ธ ์บ์‹œ 1๊ฐœ + ID ๋ฆฌ์ŠคํŠธ 6๊ฐœ **๊ฐฑ์‹ ** | +| ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ | ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” **๋ˆ„๋ฝ** (๋ฒ„๊ทธ) | ํ•ด๋‹น ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ **์ผ๊ด„ write-through** | +| ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ | evict ํ›„ ์„œ๋กœ ๋‹ค๋ฅธ ํ‚ค ๋™์‹œ miss โ†’ DB ๋ถ€ํ•˜ ํญ์ฆ | write-through๋กœ ์บ์‹œ warm ์œ ์ง€ โ†’ **์Šคํƒฌํ”ผ๋“œ ์ œ๊ฑฐ** | +| ์บ์‹œ ํžˆํŠธ์œจ (๊ณ ๋นˆ๋„ write ์‹œ) | โ‰ˆ 0% (๋งค evict๋งˆ๋‹ค ์ „์ฒด ๋ฆฌ์…‹) | **์•ˆ์ •์  ์œ ์ง€** (๊ฐฑ์‹ ์ด์ง€ ์‚ญ์ œ๊ฐ€ ์•„๋‹˜) | +| `SCAN` ๋น„์šฉ | `evictByPattern` โ†’ Redis SCAN O(N) | **์ œ๊ฑฐ๋จ** | +| PLP-PDP ์บ์‹œ ๊ณต์œ  | ๋ถˆ๊ฐ€ (๋ชฉ๋ก์€ ์ „์ฒด DTO, ์ƒ์„ธ๋Š” ๋ณ„๋„) | **๊ณต์œ ** (Layer 2 ์ƒ์„ธ ์บ์‹œ๋ฅผ PLP+PDP๊ฐ€ ์žฌํ™œ์šฉ) | + +--- + +## 7. ์š”์•ฝ + +### ๋ฌธ์ œ + +`evictByPattern("products:list:*")`์ด ์ข‹์•„์š” ๋“ฑ ๊ณ ๋นˆ๋„ mutation๋งˆ๋‹ค ์ „์ฒด ๋ชฉ๋ก ์บ์‹œ๋ฅผ ์‚ญ์ œํ•˜์—ฌ, ์บ์‹œ ํžˆํŠธ์œจ์ด ์‚ฌ์‹ค์ƒ 0%์— ์ˆ˜๋ ดํ•˜๊ณ  ์Šคํƒฌํ”ผ๋“œ ์œ„ํ—˜์„ ์ƒ์‹œ ์œ ๋ฐœํ•˜๋Š” ๊ตฌ์กฐ์˜€๋‹ค. + +### ์ „๋žต ์„ ํƒ ๊ณผ์ • + +| ๋‹จ๊ณ„ | ํŒ๋‹จ | ๊ฒฐ๊ณผ | +|------|------|------| +| 1์ฐจ ๋ถ„์„ | 6๊ฐœ ์ „๋žต์„ ์ด๋ก ์  ์žฅ๋‹จ์  + ํ”„๋กœ์ ํŠธ ์ ํ•ฉ์„ฑ์œผ๋กœ ํ‰๊ฐ€ | ์ „๋žต 5 (๋ชฉ๋ก ์บ์‹œ ์ œ๊ฑฐ)๊ฐ€ ๊ฐ€์žฅ ์ ํ•ฉ | +| ์žฌํ‰๊ฐ€ | PLP ํŠธ๋ž˜ํ”ฝ ๋ณผ๋ฅจ ๊ด€์  ๋ˆ„๋ฝ ๋ฐœ๊ฒฌ โ€” ๋‹จ๊ฑด ์†๋„๊ฐ€ ์•„๋‹Œ DB ๋ถ€ํ•˜ ์ฐจ๋‹จ์ด ์บ์‹œ์˜ ์ง„์งœ ๊ฐ€์น˜ | ์ „๋žต 5 ์ œ์™ธ, ๋ชฉ๋ก ์บ์‹œ ์œ ์ง€ ํ•„์š” | +| ์ „๋žต 6 ์„ธ๋ถ„ํ™” | ์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹(๋ฐฐํฌ ์•ˆ์ „์„ฑ)๊ณผ ๋ฐ์ดํ„ฐ ์„ธ๋Œ€ ๊ต์ฒด(๋ฌดํšจํ™”)๋ฅผ ๋ถ„๋ฆฌ | ์Šคํ‚ค๋งˆ ๋ฒ„์ €๋‹๋งŒ ์ฑ„ํƒ | +| ์ตœ์ข… ๋„์ถœ | ์บ์‹œ ๊ตฌ์กฐ ์ž์ฒด๋ฅผ ๋ณ€๊ฒฝ โ€” 1๊ณ„์ธต ์ „์ฒด DTO โ†’ **2๊ณ„์ธต ๋ถ„๋ฆฌ (ID ๋ฆฌ์ŠคํŠธ + ์ƒ์„ธ)** | TTL ์•ˆ์ „๋ง + write-through ์ฆ‰์‹œ ๊ฐฑ์‹  | + +### ์ตœ์ข… ์•„ํ‚คํ…์ฒ˜ + +``` +Layer 1: ID ๋ฆฌ์ŠคํŠธ ์บ์‹œ products:ids:v1:{brandId|all}:{sort}:{page}:{size} +Layer 2: ์ƒํ’ˆ ์ƒ์„ธ ์บ์‹œ product:v1:{productId} + +์ฝ๊ธฐ: cache-aside (miss ์‹œ DB โ†’ ์บ์‹œ ์ ์žฌ) +์“ฐ๊ธฐ: write-through (DB ์“ฐ๊ธฐ ์งํ›„ ์บ์‹œ ์ฆ‰์‹œ ๊ฐฑ์‹ ) +TTL: ์•ˆ์ „๋ง (write-through ์‹คํŒจ ์‹œ ์ตœ๋Œ€ staleness ์œˆ๋„์šฐ) +``` + +### ํ•ต์‹ฌ ๊ฐœ์„  + +- `evictByPattern` ์ œ๊ฑฐ โ†’ ์บ์‹œ ์Šคํƒฌํ”ผ๋“œ **์›์ฒœ ์ฐจ๋‹จ** +- write-through๋กœ ์บ์‹œ ํ•ญ์ƒ warm โ†’ ํžˆํŠธ์œจ **์•ˆ์ •์  ์œ ์ง€** +- 2๊ณ„์ธต ๋ถ„๋ฆฌ๋กœ PLP/PDP ์ƒ์„ธ ์บ์‹œ ๊ณต์œ  โ†’ ์บ์‹œ ํšจ์œจ **๊ทน๋Œ€ํ™”** +- ๋ธŒ๋žœ๋“œ๋ช… ๋ณ€๊ฒฝ ์‹œ ์ƒ์„ธ ์บ์‹œ ๋ฌดํšจํ™” ๋ˆ„๋ฝ **ํ•ด์†Œ** From 479db25d043a4befd2769912effdfbcc02ae697a Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 03:22:44 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20=EC=A0=84=EC=B2=B4=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=A0=90=EA=B2=80=20=EA=B2=B0=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - round5-docs/08-cross-domain-index-and-cache-analysis.md Co-Authored-By: Claude Opus 4.6 --- ...8-cross-domain-index-and-cache-analysis.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 round5-docs/08-cross-domain-index-and-cache-analysis.md diff --git a/round5-docs/08-cross-domain-index-and-cache-analysis.md b/round5-docs/08-cross-domain-index-and-cache-analysis.md new file mode 100644 index 000000000..aa3a64418 --- /dev/null +++ b/round5-docs/08-cross-domain-index-and-cache-analysis.md @@ -0,0 +1,302 @@ +# 08. ์ „์ฒด ๋„๋ฉ”์ธ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ๋ฐ ์บ์‹ฑ ๋ถ„์„ + +## 1. ๋ฐฐ๊ฒฝ + +์ƒํ’ˆ(Product) ๋„๋ฉ”์ธ์˜ Read Model + ๋ณตํ•ฉ ์ธ๋ฑ์Šค + Redis ์บ์‹œ ์ ์šฉ ์ดํ›„, +์ „์ฒด ๋„๋ฉ”์ธ์— ๋Œ€ํ•ด ์ธ๋ฑ์Šค ๋ˆ„๋ฝ ์—ฌ๋ถ€๋ฅผ ์ ๊ฒ€ํ•˜๊ณ  ์บ์‹ฑ ๊ธฐํšŒ๋ฅผ ๋ถ„์„ํ•˜์˜€๋‹ค. + +--- + +## 2. ProductReadModelEntity ์ธ๋ฑ์Šค ๋ณด์™„ + +### 2-1. ๊ธฐ์กด ์ธ๋ฑ์Šค์˜ ํ•œ๊ณ„ + +๊ธฐ์กด 3๊ฐœ ์ธ๋ฑ์Šค๋Š” `(deleted_at, brand_id, {sort_col})` ๊ตฌ์กฐ์˜€์œผ๋‚˜, ๋‘ ๊ฐ€์ง€ ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค: + +1. **์ปฌ๋Ÿผ ์ˆœ์„œ**: `deleted_at`(์นด๋””๋„๋ฆฌํ‹ฐ 2: NULL/timestamp)์ด `brand_id`(์นด๋””๋„๋ฆฌํ‹ฐ ์ˆ˜์‹ญ~์ˆ˜๋ฐฑ)๋ณด๋‹ค ์•ž์— ์œ„์น˜ โ†’ B-tree fan-out ๋ถˆ๊ท ๋“ฑ +2. **์ปค๋ฒ„๋ฆฌ์ง€ ๋ถ€์กฑ**: brandId ์—†๋Š” ์‚ฌ์šฉ์ž ์ฟผ๋ฆฌ์™€ ๊ด€๋ฆฌ์ž ์ฟผ๋ฆฌ์—์„œ ์ธ๋ฑ์Šค ํ™œ์šฉ ๋ถˆ๊ฐ€ + +**์นด๋””๋„๋ฆฌํ‹ฐ ์šฐ์„  ์›์น™์œผ๋กœ ์ˆœ์„œ ๋ณ€๊ฒฝ**: `(brand_id, deleted_at, {sort_col})` +- ๋‘ ์ปฌ๋Ÿผ ๋ชจ๋‘ equality ์กฐ๊ฑด์ด๋ฏ€๋กœ ์ธ๋ฑ์Šค ํƒ์ƒ‰ ๊ฒฐ๊ณผ(matching rows)๋Š” ์ˆœ์„œ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋™์ผ +- ์นด๋””๋„๋ฆฌํ‹ฐ๊ฐ€ ๋†’์€ `brand_id`๋ฅผ ์„ ๋‘์— ๋ฐฐ์น˜ํ•˜๋ฉด B-tree ์ฒซ ๋ ˆ๋ฒจ์˜ ๋ถ„๊ธฐ๊ฐ€ ๋” ๊ท ๋“ฑํ•ด์ ธ ์ธ๋ฑ์Šค ํŽ˜์ด์ง€ ์ ‘๊ทผ ํšจ์œจ ํ–ฅ์ƒ + +**brandId ์—†๋Š” ์‚ฌ์šฉ์ž ์ฟผ๋ฆฌ์˜ ๋ฌธ์ œ:** +``` +WHERE deleted_at IS NULL ORDER BY created_at DESC +``` +์ธ๋ฑ์Šค `(brand_id, deleted_at, created_at)`์—์„œ ์„ ๋‘ ์ปฌ๋Ÿผ `brand_id`๊ฐ€ ์ฟผ๋ฆฌ์— ์—†์œผ๋ฏ€๋กœ ์ธ๋ฑ์Šค ํ™œ์šฉ ๋ถˆ๊ฐ€ โ†’ ๋ณ„๋„ 2-column ์ธ๋ฑ์Šค `(deleted_at, sort_col)` ํ•„์š”. + +**๊ด€๋ฆฌ์ž ์ฟผ๋ฆฌ์˜ ๋ฌธ์ œ:** +``` +WHERE brand_id = ? ORDER BY created_at DESC (๋˜๋Š” ํ•„ํ„ฐ ์—†์Œ) +``` +3-column ์ธ๋ฑ์Šค์˜ `deleted_at`์ด ์ฟผ๋ฆฌ์— ์—†์œผ๋ฏ€๋กœ ์ •๋ ฌ ์ปฌ๋Ÿผ๊นŒ์ง€ ๋„๋‹ฌ ๋ถˆ๊ฐ€ โ†’ ๋ณ„๋„ 2-column ์ธ๋ฑ์Šค `(brand_id, sort_col)` ํ•„์š”. + +### 2-2. ๋ณด์™„๋œ ์ธ๋ฑ์Šค (๊ธฐ์กด 3 โ†’ ์ด 12๊ฐœ) + +| # | ์กฐํ•ฉ | ์ธ๋ฑ์Šค ์ปฌ๋Ÿผ | ์ƒํƒœ | +|---|------|------------|:----:| +| 1 | ์‚ฌ์šฉ์ž + ๋ธŒ๋žœ๋“œ + LATEST | `(brand_id, deleted_at, created_at)` | ๊ธฐ์กด (์ˆœ์„œ ๋ณ€๊ฒฝ) | +| 2 | ์‚ฌ์šฉ์ž + ๋ธŒ๋žœ๋“œ + PRICE_ASC | `(brand_id, deleted_at, price)` | ๊ธฐ์กด (์ˆœ์„œ ๋ณ€๊ฒฝ) | +| 3 | ์‚ฌ์šฉ์ž + ๋ธŒ๋žœ๋“œ + LIKES_DESC | `(brand_id, deleted_at, like_count)` | ๊ธฐ์กด (์ˆœ์„œ ๋ณ€๊ฒฝ) | +| 4 | ์‚ฌ์šฉ์ž + ์ „์ฒด + LATEST | `(deleted_at, created_at)` | **์‹ ๊ทœ** | +| 5 | ์‚ฌ์šฉ์ž + ์ „์ฒด + PRICE_ASC | `(deleted_at, price)` | **์‹ ๊ทœ** | +| 6 | ์‚ฌ์šฉ์ž + ์ „์ฒด + LIKES_DESC | `(deleted_at, like_count)` | **์‹ ๊ทœ** | +| 7 | ๊ด€๋ฆฌ์ž + ๋ธŒ๋žœ๋“œ + LATEST | `(brand_id, created_at)` | **์‹ ๊ทœ** | +| 8 | ๊ด€๋ฆฌ์ž + ๋ธŒ๋žœ๋“œ + PRICE_ASC | `(brand_id, price)` | **์‹ ๊ทœ** | +| 9 | ๊ด€๋ฆฌ์ž + ๋ธŒ๋žœ๋“œ + LIKES_DESC | `(brand_id, like_count)` | **์‹ ๊ทœ** | +| 10 | ๊ด€๋ฆฌ์ž + ์ „์ฒด + LATEST | `(created_at)` | **์‹ ๊ทœ** | +| 11 | ๊ด€๋ฆฌ์ž + ์ „์ฒด + PRICE_ASC | `(price)` | **์‹ ๊ทœ** | +| 12 | ๊ด€๋ฆฌ์ž + ์ „์ฒด + LIKES_DESC | `(like_count)` | **์‹ ๊ทœ** | + +**์ปค๋ฒ„๋ฆฌ์ง€:** ์‚ฌ์šฉ์ž/๊ด€๋ฆฌ์ž ร— ๋ธŒ๋žœ๋“œ์œ ๋ฌด ร— 3๊ฐœ ์ •๋ ฌ = **12๊ฐœ ์กฐํ•ฉ ๋ชจ๋‘ ์ธ๋ฑ์Šค ์ปค๋ฒ„**. + +--- + +## 3. ํƒ€ ๋„๋ฉ”์ธ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ + +### 3-1. ์ถ”๊ฐ€๋œ ์ธ๋ฑ์Šค ์š”์•ฝ + +| ์—”ํ‹ฐํ‹ฐ | ํ…Œ์ด๋ธ” | ์ธ๋ฑ์Šค๋ช… | ์ปฌ๋Ÿผ | ๋Œ€์ƒ ์ฟผ๋ฆฌ | +|--------|--------|---------|------|----------| +| BrandEntity | `brands` | `idx_brands_deleted_visible` | `(deleted_at, visible_status)` | ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก ์กฐํšŒ (์‚ฌ์šฉ์ž/๊ด€๋ฆฌ์ž) | +| OrderEntity | `orders` | `idx_orders_user_created` | `(user_id, created_at)` | ์ฃผ๋ฌธ ๋‚ด์—ญ ์กฐํšŒ + ๊ธฐ๊ฐ„ ํ•„ํ„ฐ | +| OrderItemEntity | `order_items` | `idx_order_items_order` | `(order_id)` | ์ฃผ๋ฌธ ์ƒํ’ˆ ์กฐํšŒ | +| CartItemEntity | `cart_items` | `idx_cart_user_selected` | `(user_id, selected)` | ์„ ํƒ๋œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ํ•ญ๋ชฉ ์กฐํšŒ | +| CartItemEntity | `cart_items` | `idx_cart_product` | `(product_id)` | ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ฆฌ (โ€ป ์ฒ˜๋ฆฌ ๋ฐฉ์‹ ๋ฏธํ™•์ • โ€” ๋…ผ์˜ ์ค‘) | +| ProductLikeEntity | `likes` | `idx_likes_user_type_created` | `(user_id, target_type, created_at)` | ์ข‹์•„์š” ๋ชฉ๋ก ํŽ˜์ด์ง€๋„ค์ด์…˜ | +| ProductLikeEntity | `likes` | `idx_likes_type_target` | `(target_type, target_id)` | ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ์ข‹์•„์š” ์ •๋ฆฌ (โ€ป ์ฆ‰์‹œ ์ •๋ฆฌ ์ œ๊ฑฐ ํ™•์ • โ€” ๋ฐฐ์น˜ ์žก ์ •๋ฆฌ ์‹œ ํ™œ์šฉ ๊ฐ€๋Šฅ) | +| IssuedCouponEntity | `issued_coupon` | `idx_issued_coupon_user_created` | `(user_id, created_at)` | ์‚ฌ์šฉ์ž ์ฟ ํฐ ๋‚ด์—ญ | +| IssuedCouponEntity | `issued_coupon` | `idx_issued_coupon_template_created` | `(coupon_template_id, created_at)` | ๊ด€๋ฆฌ์ž ์ฟ ํฐ ๋ฐœ๊ธ‰ ๋‚ด์—ญ | +| CouponTemplateEntity | `coupon_template` | `idx_coupon_template_deleted` | `(deleted_at)` | ํ™œ์„ฑ ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก | + +### 3-2. ์—”ํ‹ฐํ‹ฐ๋ณ„ ์ƒ์„ธ ๋ถ„์„ + +#### BrandEntity (`brands`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** ์—†์Œ (PK๋งŒ ์กด์žฌ) + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findAllByDeletedAtIsNull(Pageable)` โ€” ๊ด€๋ฆฌ์ž ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก +- `findAllByVisibleStatusAndDeletedAtIsNull(VisibleStatus, Pageable)` โ€” ์‚ฌ์šฉ์ž ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_brands_deleted_visible (deleted_at, visible_status) +``` +- `deleted_at`์ด ์„ ๋‘: `findAllByDeletedAtIsNull` ์ฟผ๋ฆฌ์—์„œ๋„ ์ธ๋ฑ์Šค prefix ํ™œ์šฉ ๊ฐ€๋Šฅ +- `visible_status`๊ฐ€ ํ›„์ˆœ์œ„: ์‚ฌ์šฉ์ž ์กฐํšŒ ์‹œ ์ถ”๊ฐ€ ํ•„ํ„ฐ๋ง + +--- + +#### OrderEntity (`orders`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** `UNIQUE (user_id, request_id)` โ€” ๋ฉฑ๋“ฑ์„ฑ ์ฒดํฌ์šฉ + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findByUserId(Pageable)` โ€” ์‚ฌ์šฉ์ž ์ฃผ๋ฌธ ๋‚ด์—ญ (ORDER BY created_at DESC) +- `findByUserIdAndCreatedAtInRange(userId, start, end, Pageable)` โ€” ๊ธฐ๊ฐ„๋ณ„ ์ฃผ๋ฌธ ๋‚ด์—ญ + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_orders_user_created (user_id, created_at) +``` +- `user_id` equality + `created_at` range/sort๋ฅผ ๋‹จ์ผ ์ธ๋ฑ์Šค๋กœ ์ปค๋ฒ„ +- ๋‘ ์ฟผ๋ฆฌ ํŒจํ„ด ๋ชจ๋‘ ์ง€์›: ์ „์ฒด ์กฐํšŒ(user_id๋งŒ) + ๊ธฐ๊ฐ„ ํ•„ํ„ฐ(user_id + created_at range) + +--- + +#### OrderItemEntity (`order_items`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** ์—†์Œ (PK๋งŒ ์กด์žฌ, FK ์ธ๋ฑ์Šค ์ž๋™ ์ƒ์„ฑ ์•ˆ ๋จ) + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findByOrderId(orderId)` โ€” ๋‹จ์ผ ์ฃผ๋ฌธ์˜ ์ƒํ’ˆ ๋ชฉ๋ก +- `findByOrderIdIn(orderIds)` โ€” ๋ณต์ˆ˜ ์ฃผ๋ฌธ์˜ ์ƒํ’ˆ ๋ชฉ๋ก (๋ฐฐ์น˜) + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_order_items_order (order_id) +``` +- `order_id`๊ฐ€ `@ManyToOne`์ด ์•„๋‹Œ plain Long ํ•„๋“œ์ด๋ฏ€๋กœ FK ์ธ๋ฑ์Šค๊ฐ€ ์ž๋™ ์ƒ์„ฑ๋˜์ง€ ์•Š์Œ +- ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ ์‹œ ํ•„์ˆ˜์ ์œผ๋กœ ์‚ฌ์šฉ๋˜๋Š” ์ฟผ๋ฆฌ + +--- + +#### CartItemEntity (`cart_items`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** `UNIQUE (user_id, product_id)` โ€” ๋™์ผ ์ƒํ’ˆ ์ค‘๋ณต ๋ฐฉ์ง€ + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findByUserId(userId)` โ€” ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ „์ฒด ์กฐํšŒ (UNIQUE prefix๋กœ ์ปค๋ฒ„ โœ…) +- `findByUserIdAndProductId(userId, productId)` โ€” UNIQUE ์ธ๋ฑ์Šค๋กœ ์ปค๋ฒ„ โœ… +- `findByUserIdAndSelectedTrue(userId)` โ€” ์„ ํƒ๋œ ํ•ญ๋ชฉ๋งŒ ์กฐํšŒ (**์ปค๋ฒ„ ์•ˆ ๋จ**) +- `deleteAllByProductId(productId)` โ€” ์ƒํ’ˆ ์‚ญ์ œ ์‹œ ์žฅ๋ฐ”๊ตฌ๋‹ˆ ์ •๋ฆฌ (**์ปค๋ฒ„ ์•ˆ ๋จ**) + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_cart_user_selected (user_id, selected) +idx_cart_product (product_id) +``` + +--- + +#### ProductLikeEntity / BrandLikeEntity (`likes` ๊ณต์œ  ํ…Œ์ด๋ธ”) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** `UNIQUE (user_id, target_type, target_id)` โ€” ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€ + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findByUserIdAndTargetTypeAndTargetId(...)` โ€” UNIQUE๋กœ ์ปค๋ฒ„ โœ… +- `existsByUserIdAndTargetTypeAndTargetId(...)` โ€” UNIQUE๋กœ ์ปค๋ฒ„ โœ… +- `findByUserIdAndTargetType(userId, targetType, Pageable)` โ€” ์ข‹์•„์š” ๋ชฉ๋ก (**ORDER BY created_at ๋ฏธ์ปค๋ฒ„**) +- `deleteAllByTargetTypeAndTargetId(targetType, targetId)` โ€” ์‚ญ์ œ ์‹œ ์ •๋ฆฌ (**target_type, target_id ์กฐํ•ฉ ๋ฏธ์ปค๋ฒ„**) + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_likes_user_type_created (user_id, target_type, created_at) +idx_likes_type_target (target_type, target_id) +``` +- ๋‘ ์—”ํ‹ฐํ‹ฐ๊ฐ€ ๋™์ผ ํ…Œ์ด๋ธ”์„ ๊ณต์œ ํ•˜๋ฏ€๋กœ `ProductLikeEntity`์—์„œ ํ•œ ๋ฒˆ๋งŒ ์ •์˜ +- `idx_likes_user_type_created`: ์ข‹์•„์š” ๋ชฉ๋ก ํŽ˜์ด์ง€๋„ค์ด์…˜ ์‹œ filesort ์ œ๊ฑฐ +- `idx_likes_type_target`: ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ ์‚ญ์ œ ์‹œ ๊ด€๋ จ ์ข‹์•„์š” ์ผ๊ด„ ์‚ญ์ œ ์ตœ์ ํ™” (โ€ป ์ฆ‰์‹œ ์ •๋ฆฌ ์ œ๊ฑฐ ํ™•์ • โ€” Soft delete ํ•„ํ„ฐ๋ง์œผ๋กœ ์ถฉ๋ถ„. ์ธ๋ฑ์Šค๋Š” ํ–ฅํ›„ ๋ฐฐ์น˜ ์žก ์ •๋ฆฌ ์‹œ ํ™œ์šฉ ๊ฐ€๋Šฅํ•˜๋ฏ€๋กœ ์œ ์ง€) + +--- + +#### IssuedCouponEntity (`issued_coupon`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** `UNIQUE (user_id, coupon_template_id)` โ€” 1์ธ 1์ฟ ํฐ ๋ณด์žฅ + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `existsByCouponTemplateIdAndUserId(...)` โ€” UNIQUE๋กœ ์ปค๋ฒ„ โœ… +- `findAllByUserIdOrderByCreatedAtDesc(userId)` โ€” ์‚ฌ์šฉ์ž ์ฟ ํฐ ๋‚ด์—ญ (**ORDER BY ๋ฏธ์ปค๋ฒ„**) +- `findAllByCouponTemplateIdOrderByCreatedAtDesc(couponTemplateId)` โ€” ๊ด€๋ฆฌ์ž ๋ฐœ๊ธ‰ ๋‚ด์—ญ (**๋ฏธ์ปค๋ฒ„**) + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_issued_coupon_user_created (user_id, created_at) +idx_issued_coupon_template_created (coupon_template_id, created_at) +``` + +--- + +#### CouponTemplateEntity (`coupon_template`) + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** ์—†์Œ (PK๋งŒ ์กด์žฌ) + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findAllByDeletedAtIsNull(Pageable)` โ€” ํ™œ์„ฑ ์ฟ ํฐ ํ…œํ”Œ๋ฆฟ ๋ชฉ๋ก (๊ด€๋ฆฌ์ž) + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค:** +``` +idx_coupon_template_deleted (deleted_at) +``` + +--- + +#### UserEntity (`users`) โ€” ๋ณ€๊ฒฝ ์—†์Œ + +**๊ธฐ์กด ์ธ๋ฑ์Šค:** `UNIQUE active_login_id` (generated column) โ€” ๋กœ๊ทธ์ธ ์กฐํšŒ ์ตœ์ ํ™” + +**์ฟผ๋ฆฌ ํŒจํ„ด:** +- `findByLoginIdValueAndDeletedAtIsNull(loginId)` โ€” generated column ์œ ๋‹ˆํฌ ์ธ๋ฑ์Šค๋กœ ์ปค๋ฒ„ โœ… +- `existsByLoginIdValueAndDeletedAtIsNull(loginId)` โ€” ์œ„์™€ ๋™์ผ โœ… + +**์ถ”๊ฐ€ ์ธ๋ฑ์Šค ๋ถˆํ•„์š”.** + +--- + +## 4. ์บ์‹ฑ ๊ธฐํšŒ ๋ถ„์„ + +### 4-1. ๋†’์€ ์šฐ์„ ์ˆœ์œ„ (HIGH) + +#### ๋ธŒ๋žœ๋“œ ๋ชฉ๋ก โ€” `GET /api/v1/brands` + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ๋งค ํŽ˜์ด์ง€ ๋กœ๋“œ๋งˆ๋‹ค ํ˜ธ์ถœ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | ๋ณ€๊ฒฝ ๋นˆ๋„ ๋งค์šฐ ๋‚ฎ์Œ (๊ด€๋ฆฌ์ž๋งŒ ์ˆ˜์ •) | +| ์ฟผ๋ฆฌ ๋น„์šฉ | ๋‹จ์ˆœ SELECT + pagination | +| ์ถ”์ฒœ TTL | **1์‹œ๊ฐ„** | +| ์บ์‹œ ํ‚ค | `brand:visible:page:{page}:{size}` | +| ๋ฌดํšจํ™” | ๋ธŒ๋žœ๋“œ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ/๋…ธ์ถœ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ํŒจํ„ด ์‚ญ์ œ | + +#### ๋ธŒ๋žœ๋“œ ์ƒ์„ธ โ€” `GET /api/v1/brands/{brandId}` + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ์ƒํ’ˆ ์ƒ์„ธ ์ง„์ž… ์‹œ ํ•จ๊ป˜ ์กฐํšŒ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | ๊ฑฐ์˜ ๋ถˆ๋ณ€ (์ด๋ฆ„, ์„ค๋ช…๋งŒ ๊ฐ€๋” ์ˆ˜์ •) | +| ์ฟผ๋ฆฌ ๋น„์šฉ | ๋‹จ๊ฑด PK ์กฐํšŒ (์ €๋น„์šฉ์ด๋‚˜ ๋นˆ๋„๊ฐ€ ๋†’์Œ) | +| ์ถ”์ฒœ TTL | **2์‹œ๊ฐ„** | +| ์บ์‹œ ํ‚ค | `brand:detail:{brandId}` | +| ๋ฌดํšจํ™” | ๋ธŒ๋žœ๋“œ ์ˆ˜์ •/์‚ญ์ œ ์‹œ ๊ฐœ๋ณ„ ํ‚ค ์‚ญ์ œ | + +#### ๋ฐœ๊ธ‰ ์ฟ ํฐ ๋ชฉ๋ก โ€” `GET /api/v1/users/me/coupons` + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ์ฟ ํฐํ•จ ์กฐํšŒ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | ๋ณ€๊ฒฝ ๋นˆ๋„ ๋‚ฎ์Œ (๋ฐœ๊ธ‰/์‚ฌ์šฉ ์‹œ์—๋งŒ ๋ณ€๊ฒฝ) | +| ์ฟผ๋ฆฌ ๋น„์šฉ | **N+1 ํŒจํ„ด** โ€” ์‚ฌ์šฉ์ž์˜ ๋ฐœ๊ธ‰ ์ฟ ํฐ ์กฐํšŒ ํ›„, ๊ฐ ์ฟ ํฐ์˜ ํ…œํ”Œ๋ฆฟ ์ •๋ณด ๊ฐœ๋ณ„ ์กฐํšŒ | +| ์ถ”์ฒœ TTL | **15๋ถ„** | +| ์บ์‹œ ํ‚ค | `user:{userId}:issued-coupons` | +| ๋ฌดํšจํ™” | ์ฟ ํฐ ๋ฐœ๊ธ‰/์‚ฌ์šฉ ์‹œ ํ•ด๋‹น ์‚ฌ์šฉ์ž ํ‚ค ์‚ญ์ œ | + +### 4-2. ์ค‘๊ฐ„ ์šฐ์„ ์ˆœ์œ„ (MEDIUM) + +#### ์ข‹์•„์š” ์—ฌ๋ถ€ ํ™•์ธ โ€” `GET /api/v1/users/me/product-likes/check`, `brand-likes/check` + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ ๋ธŒ๋ผ์šฐ์ง•๋งˆ๋‹ค ํ˜ธ์ถœ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | boolean ๊ฒฐ๊ณผ, ์ข‹์•„์š” ํ† ๊ธ€ ์‹œ์—๋งŒ ๋ณ€๊ฒฝ | +| ์ฟผ๋ฆฌ ๋น„์šฉ | EXISTS ์ฟผ๋ฆฌ (์ €๋น„์šฉ์ด๋‚˜ ๋งค์šฐ ๋นˆ๋ฒˆ) | +| ์ถ”์ฒœ TTL | **30๋ถ„** | +| ์บ์‹œ ํ‚ค | `user:{userId}:liked:product:{targetId}` / `brand:{targetId}` | +| ๋ฌดํšจํ™” | ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ ํ•ด๋‹น ํ‚ค ์‚ญ์ œ | + +#### ์ข‹์•„์š” ๋ชฉ๋ก โ€” `GET /api/v1/users/me/product-likes`, `brand-likes` + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ๋งˆ์ดํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ์กฐํšŒ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | ์„ธ์…˜ ์ค‘ ๋น„๊ต์  ์ •์  | +| ์ฟผ๋ฆฌ ๋น„์šฉ | ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ฟผ๋ฆฌ | +| ์ถ”์ฒœ TTL | **30๋ถ„** | +| ์บ์‹œ ํ‚ค | `user:{userId}:likes:product:page:{page}:{size}` | +| ๋ฌดํšจํ™” | ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ ํŒจํ„ด ์‚ญ์ œ | + +#### ์žฅ๋ฐ”๊ตฌ๋‹ˆ โ€” `GET` (findByUserId) + +| ํ•ญ๋ชฉ | ๊ฐ’ | +|------|---| +| ํŠธ๋ž˜ํ”ฝ | ์‚ฌ์šฉ์ž ๋Œ€๋ฉด, ์‡ผํ•‘ ์„ธ์…˜ ์ค‘ ๋นˆ๋ฒˆ ์กฐํšŒ | +| ๋ฐ์ดํ„ฐ ํŠน์„ฑ | ๋ณ€๊ฒฝ ์žฆ์Œ (์ˆ˜๋Ÿ‰ ๋ณ€๊ฒฝ, ์„ ํƒ/ํ•ด์ œ, ์ถ”๊ฐ€/์‚ญ์ œ) | +| ์ฟผ๋ฆฌ ๋น„์šฉ | ๋‹จ์ˆœ SELECT (์ €๋น„์šฉ) | +| ์ถ”์ฒœ TTL | **5๋ถ„** (์งง์€ TTL ํ•„์ˆ˜) | +| ์บ์‹œ ํ‚ค | `user:{userId}:cart:items` | +| ๋ฌดํšจํ™” | ์žฅ๋ฐ”๊ตฌ๋‹ˆ ํ•ญ๋ชฉ ๋ณ€๊ฒฝ ์‹œ ํ•ด๋‹น ์‚ฌ์šฉ์ž ํ‚ค ์‚ญ์ œ | + +### 4-3. ๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„ (LOW) โ€” ์บ์‹ฑ ๋ฏธ๊ถŒ์žฅ + +| ๋Œ€์ƒ | ๋ฏธ๊ถŒ์žฅ ์‚ฌ์œ  | +|------|-----------| +| ๊ด€๋ฆฌ์ž ์ฟผ๋ฆฌ ์ „๋ฐ˜ | ํŠธ๋ž˜ํ”ฝ ๋ฏธ๋ฏธ, ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ํ•„์š” | +| ์ฃผ๋ฌธ ๋‚ด์—ญ (`GET /api/v1/orders`) | ๋‚ ์งœ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์บ์‹œ ํ‚ค ํญ๋ฐœ, ์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ ๋นˆ๋ฒˆ | +| ์ฃผ๋ฌธ ์ƒ์„ธ (`GET /api/v1/orders/{id}`) | ์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ์ด ์žฆ์•„ ๋ฌดํšจํ™” ๋น„์šฉ > ์บ์‹ฑ ์ด์  | +| ์‚ฌ์šฉ์ž ์ธ์ฆ/์ •๋ณด (`GET /api/v1/users/me`) | ๋ณด์•ˆ ๋ฏผ๊ฐ ์ •๋ณด, ๋น„๋ฐ€๋ฒˆํ˜ธ ๋ณ€๊ฒฝ ๋“ฑ์œผ๋กœ ์บ์‹ฑ ๋ถ€์ ํ•ฉ | + +--- + +## 5. ์ˆ˜์ • ํŒŒ์ผ ๋ชฉ๋ก + +| ํŒŒ์ผ | ๋ณ€๊ฒฝ ๋‚ด์šฉ | +|------|----------| +| `ProductReadModelEntity.java` | 9๊ฐœ ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ถ”๊ฐ€ (์ด 12๊ฐœ) | +| `BrandEntity.java` | `idx_brands_deleted_visible` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `OrderEntity.java` | `idx_orders_user_created` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `OrderItemEntity.java` | `idx_order_items_order` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `CartItemEntity.java` | `idx_cart_user_selected`, `idx_cart_product` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `ProductLikeEntity.java` | `idx_likes_user_type_created`, `idx_likes_type_target` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `IssuedCouponEntity.java` | `idx_issued_coupon_user_created`, `idx_issued_coupon_template_created` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | +| `CouponTemplateEntity.java` | `idx_coupon_template_deleted` ์ธ๋ฑ์Šค ์ถ”๊ฐ€ | From d31dfd293eaffbcf3802500911e32750d3ae6710 Mon Sep 17 00:00:00 2001 From: Hwan0518 Date: Fri, 13 Mar 2026 11:46:11 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20cache=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EB=B6=84=EB=A6=AC=20(lock/,?= =?UTF-8?q?=20dto/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lock/: CacheLock, LocalCacheLock, RedisCacheLock (์Šคํƒฌํ”ผ๋“œ ๋ฐฉ์ง€ ๊ด€์‹ฌ์‚ฌ) - dto/: ProductCacheDto, IdListCacheEntry (์บ์‹œ ๊ฐ’ ํƒ€์ž…) - ProductCacheManager, ProductCacheConstants๋Š” cache/ ๋ฃจํŠธ ์œ ์ง€ - ์ „์ฒด import ๊ฒฝ๋กœ ์—…๋ฐ์ดํŠธ (main + test) Co-Authored-By: Claude Opus 4.6 --- .../port/out/query/ProductQueryPort.java | 4 +- .../service/ProductQueryService.java | 4 +- .../infrastructure/cache/LocalCacheLock.java | 46 ----------- .../cache/ProductCacheManager.java | 3 + .../cache/{ => dto}/IdListCacheEntry.java | 2 +- .../cache/{ => dto}/ProductCacheDto.java | 2 +- .../cache/{ => lock}/CacheLock.java | 2 +- .../cache/lock/LocalCacheLock.java | 78 +++++++++++++++++++ .../cache/{ => lock}/RedisCacheLock.java | 2 +- .../query/ProductQueryPortImpl.java | 4 +- .../querydsl/ProductQuerydslRepository.java | 4 +- .../service/ProductQueryServiceTest.java | 4 +- .../cache/CacheStampedeTest.java | 6 +- .../cache/ProductCacheManagerTest.java | 2 + .../cache/{ => lock}/LocalCacheLockTest.java | 14 ++-- .../query/ProductQueryPortImplTest.java | 4 +- 16 files changed, 111 insertions(+), 70 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java rename apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/{ => dto}/IdListCacheEntry.java (83%) rename apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/{ => dto}/ProductCacheDto.java (94%) rename apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/{ => lock}/CacheLock.java (83%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLock.java rename apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/{ => lock}/RedisCacheLock.java (97%) rename apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/{ => lock}/LocalCacheLockTest.java (90%) diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java index f62fd7612..52dde7dd0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/port/out/query/ProductQueryPort.java @@ -7,8 +7,8 @@ import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java index 8a164cd1e..a218cf108 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/application/service/ProductQueryService.java @@ -10,8 +10,8 @@ import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java deleted file mode 100644 index 756e20837..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLock.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.catalog.product.infrastructure.cache; - - -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Component; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; - - -/** - * JVM ๋กœ์ปฌ key-level ์บ์‹œ ๋ฝ - * - ConcurrentHashMap + synchronized๋กœ ๊ฐ™์€ key ์š”์ฒญ๋งŒ ์ง๋ ฌํ™” - * - ๋‹ค๋ฅธ key ์š”์ฒญ์€ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ (key ๋‹จ์œ„ ์„ธ๋ฐ€ํ•œ ๋ฝ) - * - ๋‹จ์ผ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ RedisCacheLock์œผ๋กœ @Primary ์ด๋™ - */ -@Primary -@Component -public class LocalCacheLock implements CacheLock { - - private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); - - - /** - * key-level ๋ฝ ์‹คํ–‰ - * 1. executeWithLock โ€” ๊ฐ™์€ key ์š”์ฒญ์€ ์ง๋ ฌํ™”, ๋‹ค๋ฅธ key๋Š” ๋ณ‘๋ ฌ - */ - - // 1. executeWithLock - @Override - public T executeWithLock(String key, Supplier loader) { - - // key๋ณ„ ๋ฝ ๊ฐ์ฒด ์ƒ์„ฑ (์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ๊ธฐ์กด ๊ฐ์ฒด ๋ฐ˜ํ™˜) - Object lock = locks.computeIfAbsent(key, k -> new Object()); - - // ๊ฐ™์€ key์— ๋Œ€ํ•ด ์ง๋ ฌํ™” ์‹คํ–‰ - synchronized (lock) { - try { - return loader.get(); - } finally { - locks.remove(key); - } - } - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java index c11acec5e..aa38217c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManager.java @@ -3,6 +3,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.lock.CacheLock; import com.loopers.config.redis.RedisConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/IdListCacheEntry.java similarity index 83% rename from apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java rename to apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/IdListCacheEntry.java index 271b80d2b..060e8fdea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/IdListCacheEntry.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/IdListCacheEntry.java @@ -1,4 +1,4 @@ -package com.loopers.catalog.product.infrastructure.cache; +package com.loopers.catalog.product.infrastructure.cache.dto; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/ProductCacheDto.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java rename to apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/ProductCacheDto.java index 841a25f6a..9d133ac22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/dto/ProductCacheDto.java @@ -1,4 +1,4 @@ -package com.loopers.catalog.product.infrastructure.cache; +package com.loopers.catalog.product.infrastructure.cache.dto; import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/CacheLock.java similarity index 83% rename from apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java rename to apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/CacheLock.java index f6bcd66dc..0506240ed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/CacheLock.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/CacheLock.java @@ -1,4 +1,4 @@ -package com.loopers.catalog.product.infrastructure.cache; +package com.loopers.catalog.product.infrastructure.cache.lock; import java.util.function.Supplier; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLock.java new file mode 100644 index 000000000..b05848af2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLock.java @@ -0,0 +1,78 @@ +package com.loopers.catalog.product.infrastructure.cache.lock; + + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; + + +/** + * JVM ๋กœ์ปฌ key-level ์บ์‹œ ๋ฝ + * - ConcurrentHashMap + ref-counted ReentrantLock์œผ๋กœ ๊ฐ™์€ key ์š”์ฒญ๋งŒ ์ง๋ ฌํ™” + * - ๋‹ค๋ฅธ key ์š”์ฒญ์€ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ (key ๋‹จ์œ„ ์„ธ๋ฐ€ํ•œ ๋ฝ) + * - ๋‹จ์ผ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์‚ฌ์šฉ. ๋ถ„์‚ฐ ํ™˜๊ฒฝ ์ „ํ™˜ ์‹œ RedisCacheLock์œผ๋กœ @Primary ์ด๋™ + */ +@Primary +@Component +public class LocalCacheLock implements CacheLock { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + + + /** + * key-level ๋ฝ ์‹คํ–‰ + * 1. executeWithLock โ€” ๊ฐ™์€ key ์š”์ฒญ์€ ์ง๋ ฌํ™”, ๋‹ค๋ฅธ key๋Š” ๋ณ‘๋ ฌ + */ + + // 1. executeWithLock + @Override + public T executeWithLock(String key, Supplier loader) { + LockHolder holder = locks.compute(key, (ignored, existing) -> { + if (existing == null) { + return new LockHolder(); + } + existing.retain(); + return existing; + }); + + holder.lock(); + try { + return loader.get(); + } finally { + holder.unlock(); + locks.compute(key, (ignored, existing) -> { + if (existing != holder) { + return existing; + } + return holder.release() ? null : holder; + }); + } + } + + private static final class LockHolder { + + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicInteger refCount = new AtomicInteger(1); + + private void retain() { + refCount.incrementAndGet(); + } + + private void lock() { + lock.lock(); + } + + private void unlock() { + lock.unlock(); + } + + private boolean release() { + return refCount.decrementAndGet() == 0; + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/RedisCacheLock.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java rename to apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/RedisCacheLock.java index 381adae0f..86dc81037 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/RedisCacheLock.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/cache/lock/RedisCacheLock.java @@ -1,4 +1,4 @@ -package com.loopers.catalog.product.infrastructure.cache; +package com.loopers.catalog.product.infrastructure.cache.lock; import com.loopers.config.redis.RedisConfig; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java index bd09bf8bb..cfae1257a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImpl.java @@ -8,8 +8,8 @@ import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.catalog.product.infrastructure.querydsl.ProductQuerydslRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; diff --git a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java index 6d85ee2f9..d3cdbe90f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/catalog/product/infrastructure/querydsl/ProductQuerydslRepository.java @@ -8,8 +8,8 @@ import com.loopers.catalog.product.domain.model.enums.ProductSortType; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.catalog.product.infrastructure.entity.QProductReadModelEntity; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java index f0fc520de..1e11ff65a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/application/service/ProductQueryServiceTest.java @@ -13,8 +13,8 @@ import com.loopers.catalog.product.domain.repository.ProductReadModelRepository; import com.loopers.catalog.product.domain.repository.vo.PageCriteria; import com.loopers.catalog.product.domain.repository.vo.PageResult; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.catalog.product.infrastructure.cache.ProductCacheManager; import com.loopers.support.common.error.CoreException; import com.loopers.support.common.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java index 76d82a796..b92de09ff 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/CacheStampedeTest.java @@ -48,7 +48,7 @@ void tearDown() { class GetOrLoadStampedeTest { @Test - @DisplayName("[getOrLoad()] single-key ์Šคํƒฌํ”ผ๋“œ - ์บ์‹œ ๋ฏธ์Šค ์ƒํƒœ์—์„œ 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> loader ํ˜ธ์ถœ ์ตœ์†Œํ™” (์ด์ƒ: 1ํšŒ)") + @DisplayName("[getOrLoad()] single-key ์Šคํƒฌํ”ผ๋“œ - ์บ์‹œ ๋ฏธ์Šค ์ƒํƒœ์—์„œ 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> loader ์ •ํ™•ํžˆ 1ํšŒ") void singleKeyStampede_loaderMinimized() throws InterruptedException { // Arrange @@ -99,8 +99,8 @@ void singleKeyStampede_loaderMinimized() throws InterruptedException { doneLatch.await(); executor.shutdown(); - // Assert โ€” loader ํ˜ธ์ถœ ์ตœ์†Œํ™” (์ด์ƒ: 1ํšŒ, ๋ ˆ์ด์Šค ์ปจ๋””์…˜ ํ—ˆ์šฉ: <= 2) - assertThat(loaderCallCount.get()).isLessThanOrEqualTo(2); + // Assert โ€” ๊ฐ™์€ key miss๋Š” 1ํšŒ๋งŒ DB ๋กœ๋“œ๋˜๊ณ  ๋‚˜๋จธ์ง€๋Š” double-check๋กœ ์บ์‹œ ์žฌ์‚ฌ์šฉ + assertThat(loaderCallCount.get()).isEqualTo(1); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java index 65eb4cbd0..2c028fc68 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/ProductCacheManagerTest.java @@ -4,6 +4,8 @@ import com.loopers.catalog.product.application.dto.out.ProductDetailOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductPageOutDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.RedisCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLockTest.java similarity index 90% rename from apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java rename to apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLockTest.java index 6d9665970..e906b0b8e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/LocalCacheLockTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/cache/lock/LocalCacheLockTest.java @@ -1,4 +1,4 @@ -package com.loopers.catalog.product.infrastructure.cache; +package com.loopers.catalog.product.infrastructure.cache.lock; import org.junit.jupiter.api.BeforeEach; @@ -32,7 +32,7 @@ void setUp() { class ExecuteWithLockTest { @Test - @DisplayName("[executeWithLock()] ๊ฐ™์€ key 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> ์ง๋ ฌ ์‹คํ–‰์œผ๋กœ loader 100ํšŒ ํ˜ธ์ถœ. ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋Š” CacheManager double-check์—์„œ ์ˆ˜ํ–‰") + @DisplayName("[executeWithLock()] ๊ฐ™์€ key 100๊ฐœ ๋™์‹œ ์š”์ฒญ -> ์ตœ๋Œ€ ๋™์‹œ ์‹คํ–‰ ์ˆ˜ 1, ์ˆœ์ฐจ ์ง๋ ฌํ™”") void sameKeyConcurrentRequests_loaderCalledOnce() throws InterruptedException { // Arrange @@ -42,6 +42,8 @@ void sameKeyConcurrentRequests_loaderCalledOnce() throws InterruptedException { CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch doneLatch = new CountDownLatch(threadCount); AtomicInteger loaderCallCount = new AtomicInteger(0); + AtomicInteger concurrentCount = new AtomicInteger(0); + AtomicInteger maxConcurrent = new AtomicInteger(0); String key = "same-key"; // Act @@ -52,12 +54,16 @@ void sameKeyConcurrentRequests_loaderCalledOnce() throws InterruptedException { startLatch.await(); localCacheLock.executeWithLock(key, () -> { loaderCallCount.incrementAndGet(); + int current = concurrentCount.incrementAndGet(); + maxConcurrent.updateAndGet(max -> Math.max(max, current)); // loader ์‹คํ–‰์— ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฌ๋Š” ์ƒํ™ฉ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); + } finally { + concurrentCount.decrementAndGet(); } return "result"; @@ -76,10 +82,8 @@ void sameKeyConcurrentRequests_loaderCalledOnce() throws InterruptedException { doneLatch.await(); executor.shutdown(); - // Assert โ€” ๋ฝ์— ์˜ํ•ด loader๋Š” ์ง๋ ฌ ์‹คํ–‰๋˜๋ฏ€๋กœ 100ํšŒ ํ˜ธ์ถœ (๋Œ€๊ธฐ ํ›„ ์ˆœ์ฐจ ์‹คํ–‰) - // LocalCacheLock์€ ๊ฒฐ๊ณผ ์บ์‹ฑ์„ ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ ๊ฐ ์Šค๋ ˆ๋“œ๊ฐ€ ์ˆœ์„œ๋Œ€๋กœ loader ํ˜ธ์ถœ - // ์‹ค์ œ ์Šคํƒฌํ”ผ๋“œ ๋ณดํ˜ธ๋Š” ProductCacheManager์˜ double-check ํŒจํ„ด์—์„œ ์ˆ˜ํ–‰ assertThat(loaderCallCount.get()).isEqualTo(threadCount); + assertThat(maxConcurrent.get()).isEqualTo(1); } diff --git a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java index 60837bfa0..2ebcfe23b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/catalog/product/infrastructure/query/ProductQueryPortImplTest.java @@ -8,8 +8,8 @@ import com.loopers.catalog.product.application.dto.out.AdminProductOutDto; import com.loopers.catalog.product.application.dto.out.ProductOutDto; import com.loopers.catalog.product.application.port.out.query.ProductQueryPort; -import com.loopers.catalog.product.infrastructure.cache.IdListCacheEntry; -import com.loopers.catalog.product.infrastructure.cache.ProductCacheDto; +import com.loopers.catalog.product.infrastructure.cache.dto.IdListCacheEntry; +import com.loopers.catalog.product.infrastructure.cache.dto.ProductCacheDto; import com.loopers.catalog.product.application.port.out.query.criteria.ProductSearchCriteria; import com.loopers.catalog.product.domain.model.Product; import com.loopers.catalog.product.domain.model.enums.ProductSortType;