From 4c214ffefa198a3872297e8e1673e48d12da4174 Mon Sep 17 00:00:00 2001
From: leeedohyun
Date: Mon, 9 Mar 2026 10:43:21 +0900
Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20ProductSortType=EC=97=90?=
=?UTF-8?q?=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=202=EC=B0=A8?=
=?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4(createdAt=20DESC)=20?=
=?UTF-8?q?=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
.../java/com/loopers/domain/product/ProductSortType.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java
index 6cbf30385..f96f8c17c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java
@@ -13,8 +13,8 @@
public enum ProductSortType {
CREATED_AT_DESC(Sort.by(Sort.Direction.DESC, "createdAt")),
- PRICE_ASC(Sort.by(Sort.Direction.ASC, "price.amount").and(Sort.by(Sort.Direction.DESC, "createdAt"))),
- LIKE_COUNT_DESC(Sort.by(Sort.Direction.DESC, "likeCount").and(Sort.by(Sort.Direction.DESC, "createdAt")));
+ PRICE_ASC(Sort.by(Sort.Direction.ASC, "price.amount")),
+ LIKE_COUNT_DESC(Sort.by(Sort.Direction.DESC, "likeCount"));
public static final ProductSortType DEFAULT = CREATED_AT_DESC;
@@ -30,4 +30,4 @@ public static ProductSortType from(String value) {
throw new CoreException(ErrorType.INVALID_SORT_TYPE);
}
}
-}
\ No newline at end of file
+}
From 995a4619e6867547d6c8dc7ad07cb90ffab7c94b Mon Sep 17 00:00:00 2001
From: leeedohyun
Date: Tue, 10 Mar 2026 16:28:14 +0900
Subject: [PATCH 02/11] =?UTF-8?q?docs:=20=EC=83=81=ED=92=88=20=EB=AA=A9?=
=?UTF-8?q?=EB=A1=9D=20=EB=B6=80=ED=95=98=ED=85=8C=EC=8A=A4=ED=8A=B8=20?=
=?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=B0=8F=20=EC=84=B1?=
=?UTF-8?q?=EB=8A=A5=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
.docs/performance/performance-base.md | 120 ++++++++++++++++++++++++++
.gitignore | 3 +
k6/product-detail-test.js | 66 ++++++++++++++
k6/product-list-test.js | 102 ++++++++++++++++++++++
k6/run.sh | 8 ++
5 files changed, 299 insertions(+)
create mode 100644 .docs/performance/performance-base.md
create mode 100644 k6/product-detail-test.js
create mode 100644 k6/product-list-test.js
create mode 100755 k6/run.sh
diff --git a/.docs/performance/performance-base.md b/.docs/performance/performance-base.md
new file mode 100644
index 000000000..2bbb16186
--- /dev/null
+++ b/.docs/performance/performance-base.md
@@ -0,0 +1,120 @@
+# 성능 테스트 환경 및 개선 전 측정값
+
+## 1. 테스트 환경
+
+### 1.1 하드웨어
+
+| 항목 | 사양 |
+|------|------|
+| Machine | MacBook M1 |
+| Memory | 16GB |
+| Storage | 256GB SSD |
+
+### 1.2 소프트웨어
+
+| 구성 요소 | 버전 | 비고 |
+|-----------|------|------|
+| Spring Boot | 3.4.4 | commerce-api (로컬 실행) |
+| MySQL | 8.0 | Docker 컨테이너 |
+| Redis | 7.0 | Docker 컨테이너 |
+| Kafka | 3.5.1 | Docker 컨테이너 |
+| k6 | - | 부하 테스트 도구 |
+
+---
+
+## 2. 데이터 규모
+
+| 테이블 | 건수 | 비고 |
+|--------|------|------|
+| brand | 23,250 | 5계층 롱테일 분포 |
+| product | 500,000 | 브랜드 규모별 차등 분배 |
+| likes | ~972,000 | 4계층 롱테일 분포 |
+
+### 브랜드 분포
+
+실제 서비스의 롱테일 분포를 재현하기 위해 5계층으로 설계했다.
+
+| 계층 | 브랜드 수 | 브랜드당 상품 | 소계 |
+|------|----------|-------------|------|
+| 대형 (1~50) | 50 | 500 | 25,000 |
+| 중형 (51~250) | 200 | 200 | 40,000 |
+| 소형 (251~1,250) | 1,000 | 80 | 80,000 |
+| 영세 (1,251~6,250) | 5,000 | 20 | 100,000 |
+| 신규 (6,251~23,250) | 17,000 | 15 | 255,000 |
+
+### 좋아요 분포
+
+실제 서비스의 인기 상품 쏠림을 재현하기 위해 4계층 롱테일 분포를 적용했다. Top 50개 상품이 전체 좋아요의 ~90%를 독점한다.
+
+| 계층 | 상품 ID | 상품 수 | 좋아요/상품 | 소계 | 비중 |
+|------|---------|---------|------------|------|------|
+| Top | 1~50 | 50 | 15,100~20,000 | ~877K | 90% |
+| High | 51~150 | 100 | 304~700 | ~50K | 5% |
+| Mid | 151~650 | 500 | 40~60 | ~25K | 3% |
+| Tail | 651~5,650 | 5,000 | 4 | ~20K | 2% |
+| 없음 | 5,651~500,000 | 494,350 | 0 | 0 | 0% |
+
+---
+
+## 3. 부하 프로파일
+
+```
+Stage 1: Warm-up 0 → 50 VU (30s)
+Stage 2: Ramp-up 50 → 100 VU (1m)
+Stage 3: Stress 100 → 200 VU (30s)
+Stage 4: Ramp-down 200 → 0 VU (1m)
+```
+
+- 총 약 3분, 8개 시나리오 (목록 조회 6 + 상세 조회 2), weight 기반 VU 분배
+
+---
+
+## 4. 개선 전 측정값 (인덱스 없음, 200 VU)
+
+### 4.1 단일 쿼리 실행 시간
+
+| 쿼리 | 소요 시간 |
+|------|----------|
+| 좋아요순 page 0 | 17,315ms |
+| 좋아요순 page 49 | 23,004ms |
+| 최신순 page 0 | 3,307ms |
+| 최신순 page 49 | 3,374ms |
+| 가격순 page 0 | 2,474ms |
+| 가격순 page 49 | 2,410ms |
+| 브랜드+좋아요순 page 0 | 4,600ms |
+| 브랜드+좋아요순 deep | 2,726ms |
+| 브랜드+최신순 page 0 | 2,786ms |
+| 브랜드+최신순 deep | 2,108ms |
+| 브랜드+가격순 page 0 | 1,304ms |
+| 브랜드+가격순 deep | 1,603ms |
+| 상세 조회 (id=1) | 0.0002ms |
+| 상세 조회 (id=3000) | 0.0002ms |
+
+- 목록 조회: PK 외 인덱스 없음 → 50만 건 풀스캔 + filesort
+- 상세 조회: PK 조회로 1ms 미만
+
+### 4.2 부하 테스트 지표 — 상품 목록 조회
+
+| 지표 | 값 |
+|------|-----|
+| RPS | 17.49/s |
+| 에러율 | 90.5% |
+| avg latency | 5s |
+| med latency | 3s |
+| p90 latency | 6s |
+| p95 latency | 23s |
+| p99 latency | 32s |
+| max latency | 47s |
+
+### 4.3 부하 테스트 지표 — 상품 상세 조회 (PK 조회)
+
+| 지표 | 값 |
+|------|-----|
+| RPS | 16.39/s |
+| 에러율 | 67.7% |
+| avg latency | 5s |
+| med latency | 3s |
+| p90 latency | 10s |
+| p95 latency | 11s |
+| p99 latency | 12s |
+| max latency | 14s |
diff --git a/.gitignore b/.gitignore
index 7012b1326..d3aec2333 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ out/
### Conversations ###
.docs/conversations/
+
+### k6 ###
+k6/*-report.html
diff --git a/k6/product-detail-test.js b/k6/product-detail-test.js
new file mode 100644
index 000000000..c790ca969
--- /dev/null
+++ b/k6/product-detail-test.js
@@ -0,0 +1,66 @@
+import http from 'k6/http';
+import { check } from 'k6';
+
+// ─── Config ─────────────────────────────────────────────
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const LOGIN_ID = __ENV.LOGIN_ID || 'loopers';
+const LOGIN_PW = __ENV.LOGIN_PW || 'loopersloopers';
+
+// 테스트 대상 상품 ID 범위 (DB에 존재하는 범위에 맞게 조정)
+const MIN_PRODUCT_ID = 1;
+const MAX_PRODUCT_ID = __ENV.MAX_PRODUCT_ID ? parseInt(__ENV.MAX_PRODUCT_ID) : 1000;
+
+// ─── Load Stages ────────────────────────────────────────
+// Warm-up(30s) → Ramp-up(1m) → Stress(30s) → Ramp-down(1m), 총 약 3분
+const LOAD_STAGES = [
+ { duration: '30s', target: 50 },
+ { duration: '1m', target: 100 },
+ { duration: '30s', target: 200 },
+ { duration: '1m', target: 0 },
+];
+
+// ─── Options ────────────────────────────────────────────
+export const options = {
+ scenarios: {
+ productDetail: {
+ executor: 'ramping-vus',
+ startVUs: 0,
+ stages: LOAD_STAGES,
+ exec: 'productDetail',
+ },
+ },
+ thresholds: {
+ 'http_req_duration': ['p(95)<500', 'p(99)<1000'],
+ 'http_req_failed': ['rate<0.01'],
+ },
+};
+
+// ─── HTTP Headers ───────────────────────────────────────
+const HEADERS = {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Loopers-LoginId': LOGIN_ID,
+ 'X-Loopers-LoginPw': LOGIN_PW,
+ },
+};
+
+// ─── Random Generators ──────────────────────────────────
+
+/** 인기 상품에 트래픽이 집중되는 롱테일 분포 시뮬레이션 */
+function randomProductId() {
+ const r = Math.random();
+ // 80%의 요청이 상위 20% 상품에 집중
+ const range = MAX_PRODUCT_ID - MIN_PRODUCT_ID + 1;
+ if (r < 0.8) {
+ return MIN_PRODUCT_ID + Math.floor(Math.random() * Math.ceil(range * 0.2));
+ }
+ return MIN_PRODUCT_ID + Math.floor(Math.random() * range);
+}
+
+// ─── Scenario Function ─────────────────────────────────
+
+export function productDetail() {
+ const productId = randomProductId();
+ const res = http.get(`${BASE_URL}/api/v1/products/${productId}`, HEADERS);
+ check(res, { 'status is 200': (r) => r.status === 200 });
+}
diff --git a/k6/product-list-test.js b/k6/product-list-test.js
new file mode 100644
index 000000000..b42420a52
--- /dev/null
+++ b/k6/product-list-test.js
@@ -0,0 +1,102 @@
+import http from 'k6/http';
+import { check } from 'k6';
+
+// ─── Config ─────────────────────────────────────────────
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+const LOGIN_ID = __ENV.LOGIN_ID || 'loopers';
+const LOGIN_PW = __ENV.LOGIN_PW || 'loopersloopers';
+
+// ─── Load Stages ────────────────────────────────────────
+// Warm-up(30s) → Ramp-up(1m) → Stress(30s) → Ramp-down(1m), 총 약 3분
+const LOAD_STAGES = [
+ { duration: '30s', target: 50 },
+ { duration: '1m', target: 100 },
+ { duration: '30s', target: 200 },
+ { duration: '1m', target: 0 },
+];
+
+// ─── Fixed Parameters ───────────────────────────────────
+const PAGE = 0;
+const SIZE = 20;
+
+// ─── Scenario Weights ───────────────────────────────────
+// 정렬 옵션 × 인증 여부 × 브랜드 필터 조합
+const SCENARIO_WEIGHTS = {
+ listByLikesAsGuest: 0.20, // 좋아요순 · 비회원
+ listByLikesAsMember: 0.15, // 좋아요순 · 회원
+ listByLatestAsGuest: 0.15, // 최신순 · 비회원
+ listByLatestAsMember: 0.10, // 최신순 · 회원
+ listByPriceAsGuest: 0.10, // 가격순 · 비회원
+ listByPriceAsMember: 0.05, // 가격순 · 회원
+ listByBrandLikesAsGuest: 0.15, // 브랜드+좋아요순 · 비회원
+ listByBrandLikesAsMember: 0.10, // 브랜드+좋아요순 · 회원
+};
+
+// ─── Options ────────────────────────────────────────────
+const scenarios = {};
+for (const [name, weight] of Object.entries(SCENARIO_WEIGHTS)) {
+ scenarios[name] = {
+ executor: 'ramping-vus',
+ startVUs: 0,
+ stages: LOAD_STAGES.map((s) => ({
+ duration: s.duration,
+ target: Math.max(Math.ceil(s.target * weight), s.target > 0 ? 1 : 0),
+ })),
+ exec: name,
+ };
+}
+
+export const options = {
+ scenarios,
+ thresholds: {
+ 'http_req_duration': ['p(95)<500', 'p(99)<1000'],
+ 'http_req_failed': ['rate<0.01'],
+ },
+};
+
+// ─── HTTP Headers ───────────────────────────────────────
+const GUEST_HEADERS = {
+ headers: { 'Content-Type': 'application/json' },
+};
+
+const MEMBER_HEADERS = {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Loopers-LoginId': LOGIN_ID,
+ 'X-Loopers-LoginPw': LOGIN_PW,
+ },
+};
+
+// ─── Random Generators ──────────────────────────────────
+
+/** brandId 1~10 중 랜덤 */
+function randomBrandId() {
+ return Math.floor(Math.random() * 10) + 1;
+}
+
+// ─── Request Helpers ────────────────────────────────────
+
+function fetchProductList(sort, httpParams, brandId) {
+ let url = `${BASE_URL}/api/v1/products?sort=${sort}&page=${PAGE}&size=${SIZE}`;
+ if (brandId) url += `&brandId=${brandId}`;
+ const res = http.get(url, httpParams);
+ check(res, { 'status is 200': (r) => r.status === 200 });
+}
+
+// ─── Scenario Functions ─────────────────────────────────
+
+// 목록 조회 — 좋아요순
+export function listByLikesAsGuest() { fetchProductList('LIKE_COUNT_DESC', GUEST_HEADERS); }
+export function listByLikesAsMember() { fetchProductList('LIKE_COUNT_DESC', MEMBER_HEADERS); }
+
+// 목록 조회 — 최신순
+export function listByLatestAsGuest() { fetchProductList('CREATED_AT_DESC', GUEST_HEADERS); }
+export function listByLatestAsMember() { fetchProductList('CREATED_AT_DESC', MEMBER_HEADERS); }
+
+// 목록 조회 — 가격순
+export function listByPriceAsGuest() { fetchProductList('PRICE_ASC', GUEST_HEADERS); }
+export function listByPriceAsMember() { fetchProductList('PRICE_ASC', MEMBER_HEADERS); }
+
+// 목록 조회 — 브랜드 + 좋아요순
+export function listByBrandLikesAsGuest() { fetchProductList('LIKE_COUNT_DESC', GUEST_HEADERS, randomBrandId()); }
+export function listByBrandLikesAsMember() { fetchProductList('LIKE_COUNT_DESC', MEMBER_HEADERS, randomBrandId()); }
diff --git a/k6/run.sh b/k6/run.sh
new file mode 100755
index 000000000..f7f5696e0
--- /dev/null
+++ b/k6/run.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+SCRIPT="${1:?Usage: ./k6/run.sh [k6 options...]}"
+shift
+
+K6_WEB_DASHBOARD=true \
+K6_WEB_DASHBOARD_EXPORT="k6/${SCRIPT}-report.html" \
+k6 run "k6/${SCRIPT}-test.js" "$@"
From a716baeb532f5d41f357498ab0310cad8d91c789 Mon Sep 17 00:00:00 2001
From: leeedohyun
Date: Thu, 12 Mar 2026 01:36:08 +0900
Subject: [PATCH 03/11] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=97=94?=
=?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=97=90=20=EC=A0=95=EB=A0=AC=C2=B7=EB=B8=8C?=
=?UTF-8?q?=EB=9E=9C=EB=93=9C=20=EB=B3=B5=ED=95=A9=20=EC=9D=B8=EB=8D=B1?=
=?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
.docs/performance/performance-report-index.md | 201 ++++++++++++++++++
.../com/loopers/domain/product/Product.java | 18 +-
2 files changed, 218 insertions(+), 1 deletion(-)
create mode 100644 .docs/performance/performance-report-index.md
diff --git a/.docs/performance/performance-report-index.md b/.docs/performance/performance-report-index.md
new file mode 100644
index 000000000..c31fad526
--- /dev/null
+++ b/.docs/performance/performance-report-index.md
@@ -0,0 +1,201 @@
+# 상품 조회 API 성능 개선 보고서 — 인덱스 최적화
+
+> 테스트 환경, 데이터 분포, 개선 전 측정값은 [performance-base.md](performance-base.md) 참조
+
+## 1. 개요
+
+상품 목록/상세 조회 API에서 PK 외 인덱스가 없어 50만 건 풀스캔 + filesort가 발생하는 문제를 인덱스 최적화로 해결한다.
+
+### 1.1 배경
+
+- 상품 목록 조회 시 PK 외 인덱스가 없어 **50만 건 풀스캔 + filesort** 발생
+- 단일 쿼리 실행 시간: 1,304ms~17,315ms
+- 200 VU 부하 테스트에서 **에러율 63.9%**, p95 11s, p99 14s로 서비스 불가 수준
+
+---
+
+## 2. 목표 설정
+
+개선 전 병목은 **50만 건 풀스캔 + filesort**이며, 인덱스 적용으로 쿼리 실행 시간이 수초 → ms 단위 수준으로 단축되는 것을 실험으로 확인했다. 이를 바탕으로 목표를 산출한다.
+
+| 지표 | 개선 전 | 목표 | 산출 근거 |
+|------|-----------|------|-----------|
+| RPS | 17.49/s | **100/s 이상** | 쿼리 응답시간 대폭 단축 → 처리량 비례 증가. 로컬 환경 제약(단일 WAS, Docker DB)으로 보수적 산정 |
+| 에러율 | 90.5% | **1% 미만** | 풀스캔 제거로 DB CPU/IO 병목 해소 → 타임아웃 에러 소멸 |
+| avg | 5s | **500ms 이하** | 쿼리 자체는 ms 단위이나, 200 VU 동시 접속에 따른 커넥션 풀/CPU 경합 고려 |
+| p95 | 23s | **1s 이하** | 인덱스 적용 후 단일 쿼리 ms 단위. 동시 접속 경합만 남음 |
+| p99 | 32s | **2s 이하** | 브랜드 필터 최악 케이스 감안 |
+
+> **산출 방식**: 쿼리 레벨에서는 수백~수천 배 개선이 확인되었으나, 200 VU 동시 접속 환경에서는 커넥션 풀·GC·네트워크 등 DB 외 병목이 지배적이 되므로, 시스템 수준에서는 3~5배 개선을 목표로 설정했다.
+
+---
+
+## 3. 병목 분석
+
+인덱스 없는 상태에서 목록 조회 쿼리의 실행 계획:
+
+```
+Table scan on product (cost=50872, rows=494668)
+→ Filter: deleted_at is null
+→ Sort: filesort
+→ Limit
+```
+
+- **풀스캔**: 50만 건 전체를 읽고 필터링
+- **filesort**: 정렬 결과를 메모리/디스크에서 정렬 후 LIMIT 적용
+- 단일 쿼리 실행 시간: 1,304ms~17,315ms
+
+---
+
+## 4. 개선 전략
+
+### 4.1 인덱스 방향 설계 — DESC 인덱스
+
+InnoDB B+Tree에서 ASC 인덱스를 DESC ORDER BY로 탐색하면 **backward scan (reverse)**이 발생한다. Backward scan은 forward scan 대비 구조적으로 느리다:
+
+1. **페이지 래치 비대칭**: InnoDB는 페이지 래치를 왼쪽→오른쪽으로만 획득한다. Backward scan은 이전 페이지로 이동할 때 현재 래치를 해제하고 이전 페이지의 래치를 새로 획득해야 하므로, mini-transaction을 커밋/재시작하는 오버헤드가 발생한다.
+2. **페이지 내 레코드 탐색**: 페이지 내부의 레코드는 단방향 연결 리스트로 저장된다. Forward scan은 next 포인터를 따라가면 되지만, backward scan은 Page Directory에서 이진 탐색 후 2~4개 레코드를 추가 순회해야 한다.
+3. **동시성 영향**: 단일 쿼리에서는 ~29% 차이이나, 높은 동시성에서는 래치 경합으로 인해 최대 44%까지 처리량 차이가 발생할 수 있다.
+
+따라서 DESC 정렬이 빈번한 좋아요순/최신순에는 **DESC 인덱스**(MySQL 8.0+)를 생성하여 forward scan을 유도한다.
+
+> 참고: [카카오 기술 블로그 — InnoDB Backward Index Scan](https://tech.kakao.com/posts/351)
+
+### 4.2 브랜드 필터 인덱스 설계 — 복합 vs 단일
+
+쿼리 패턴: 2가지(전체/브랜드 필터) × 3가지 정렬 = 6가지
+
+**후보 A: 정렬 3개 + 브랜드 복합 3개 (6개)**
+
+| 인덱스 | 컬럼 | 커버하는 쿼리 |
+|--------|------|--------------|
+| `idx_product_like_count` | `(like_count DESC)` | 전체 좋아요순 |
+| `idx_product_created_at` | `(created_at DESC)` | 전체 최신순 |
+| `idx_product_price` | `(price ASC)` | 전체 가격순 |
+| `idx_product_brand_like_count` | `(brand_id, like_count)` | 브랜드+좋아요순 |
+| `idx_product_brand_created_at` | `(brand_id, created_at)` | 브랜드+최신순 |
+| `idx_product_brand_price` | `(brand_id, price)` | 브랜드+가격순 |
+
+- 브랜드 쿼리에서 filesort 완전 제거
+- like_count 갱신 시 **인덱스 2개** 갱신 필요 (`like_count`, `brand_id, like_count`)
+
+**후보 B: 정렬 3개 + brand_id 단일 (4개)**
+
+| 인덱스 | 컬럼 | 커버하는 쿼리 |
+|--------|------|--------------|
+| `idx_product_like_count` | `(like_count DESC)` | 전체 좋아요순 |
+| `idx_product_created_at` | `(created_at DESC)` | 전체 최신순 |
+| `idx_product_price` | `(price ASC)` | 전체 가격순 |
+| `idx_product_brand_id` | `(brand_id)` | 브랜드 필터 + 모든 정렬 |
+
+- 브랜드 쿼리: brand_id로 ref lookup → 소규모 결과셋에 filesort
+- like_count 갱신 시 **인덱스 1개**만 갱신
+
+**브랜드 필터 인덱스 실험 결과:**
+
+> 데이터: 브랜드 23,250개 롱테일 분포, 대형 브랜드 (brand_id=1, 500건) 기준
+
+| 쿼리 | 정렬 인덱스 재활용 | + brand_id 단일 | + brand 복합 |
+|------|--------------------|----------------|-------------|
+| 브랜드+좋아요순 p0 | 0.89ms ² | 1.42ms | 0.49ms |
+| 브랜드+최신순 p0 | 5,465ms ✗ | 6.36ms ¹ | 0.27ms |
+| 브랜드+가격순 p0 | 1,170ms ✗ | 0.62ms | 0.46ms |
+
+¹ FORCE INDEX 사용 (시드 데이터의 created_at 동일로 옵티마이저 오판)
+² 데이터 분포 의존 (brand_id=1에 Top 좋아요 상품 집중)
+
+- **정렬 인덱스 재활용 실패**: 23,250개 브랜드에서 brand_id=1은 전체의 0.1% (500/500,000). 정렬 인덱스를 스캔하면 대부분의 행이 brand_id 필터에서 탈락하여 최대 499,521건 스캔
+- **brand_id 단일**: ref lookup으로 500건 한정 → 메모리 filesort → 최악 6.36ms. 대형 브랜드 500건이 최악이고, 소형(80건)~신규(15건)는 sub-ms
+- **brand 복합**: filesort 제거로 0.27~0.49ms이나, 인덱스 2개 추가 + like_count 갱신 비용 증가
+
+### 4.3 최종 결정 — 후보 A (6개)
+
+```
+(like_count DESC), (created_at DESC), (price ASC),
+(brand_id, like_count DESC), (brand_id, created_at DESC), (brand_id, price ASC)
+```
+
+**선정 근거:**
+- 브랜드 복합 인덱스로 브랜드 필터 쿼리에서 **filesort 완전 제거** — 모든 쿼리 0.05~0.8ms
+- 부하 테스트에서 RPS 20% 향상 확인 (159.54 → 191.91/s) — filesort 제거가 동시 접속 환경에서 유의미한 효과
+- like_count 갱신 시 인덱스 2개 갱신 비용이 있으나, 읽기 QPS가 쓰기 대비 압도적으로 높아 읽기 최적화 우선
+
+**설계 의도:**
+- 전체 조회: DESC 방향 인덱스로 **forward scan** 유도 → backward scan의 래치 오버헤드 제거
+- 브랜드 필터 조회: `(brand_id, sort_col)` 복합 인덱스로 ref lookup + **forward scan** (filesort 없음)
+- `deleted_at`은 카디널리티가 ~1 (거의 모든 행이 NULL)이므로 복합 인덱스 선두 컬럼으로서 가치 없음
+
+---
+
+## 5. 적용 결과
+
+### 5.1 단일 쿼리 개선
+
+**전체 조회 (DESC 인덱스, forward scan)**
+
+| 쿼리 | 개선 전 | 개선 후 (p0 / p49) | 개선 배율 |
+|------|---------|-------------------|-----------|
+| 좋아요순 | 17,315ms / 23,004ms | **0.10ms / 2.44ms** | ~173,000x / ~9,400x |
+| 최신순 | 3,307ms / 3,374ms | **0.30ms / 7.53ms** | ~11,000x / ~450x |
+| 가격순 | 2,474ms / 2,410ms | **0.13ms / 4.26ms** | ~19,000x / ~570x |
+
+- EXPLAIN에서 `(reverse)` 표시 없음 — 모든 쿼리가 forward scan으로 실행됨
+
+**브랜드 필터 조회 (brand 복합 인덱스, filesort 없음)**
+
+| 쿼리 | 개선 전 | 개선 후 (p0 / deep) | 개선 배율 |
+|------|---------|---------------------|-----------|
+| 브랜드+좋아요순 | 4,600ms / 2,726ms | **0.159ms / 0.791ms** | ~29,000x / ~3,400x |
+| 브랜드+최신순 | 2,786ms / 2,108ms | **0.057ms / 0.261ms** | ~49,000x / ~8,100x |
+| 브랜드+가격순 | 1,304ms / 1,603ms | **0.050ms / 0.451ms** | ~26,000x / ~3,600x |
+
+- `(brand_id, sort_col)` 복합 인덱스로 ref lookup + forward scan — filesort 완전 제거
+
+### 5.2 부하 테스트 — 상품 목록 조회 (200 VU)
+
+| 지표 | 개선 전 | 인덱스 적용 후 | 목표 | 달성 |
+|------|--------|---------------|------|------|
+| RPS | 17.49/s | **191.91/s** | 100/s 이상 | O |
+| 에러율 | 90.5% | **0.1%** | 1% 미만 | O |
+| avg | 5s | **455ms** | 500ms 이하 | O |
+| p95 | 23s | **2s** | 1s 이하 | X |
+| p99 | 32s | **4s** | 2s 이하 | X |
+
+### 5.3 부하 테스트 — 상품 상세 조회 (200 VU)
+
+| 지표 | 개선 전 | 인덱스 적용 후 |
+|------|--------|---------------|
+| RPS | 16.39/s | **23.47/s** |
+| 에러율 | 67.7% | **9.0%** |
+| avg | 5s | **3s** |
+| p95 | 11s | **7s** |
+| p99 | 12s | **8s** |
+
+- 상세 조회는 원래 PK 조회(0.0002ms)로 쿼리 자체가 빠르며, 인덱스 추가의 영향 없음
+- 에러 원인: HikariCP 커넥션 풀(40) 고갈 — 200VU × 요청당 DB 3회(Product + Brand + Like)로 커넥션 풀 초과
+- 인덱스 최적화의 대상이 아닌 **커넥션 풀 경합** 문제로, 캐싱 등 DB 접근 횟수 감소가 필요
+
+---
+
+## 6. 분석 및 결론
+
+### 6.1 개선 효과 — 상품 목록 조회
+
+- **RPS 11배 향상** (17.49 → 191.91/s): 풀스캔 제거 + DESC forward scan + 브랜드 복합 인덱스로 DB CPU/IO 부하 대폭 감소
+- **에러율 90.5% → 0.1%**: 쿼리 타임아웃 사실상 소멸
+- RPS, 에러율, avg 목표 달성. 단, p95(2s), p99(4s)는 목표 미달
+
+### 6.2 인덱스 최적화 포인트
+
+- **DESC 인덱스**: ASC 인덱스의 backward scan 대비 forward scan으로 전환. 좋아요순 p49 기준 16.3ms→2.44ms (7배), 최신순 p0 기준 17ms→0.30ms (57배) 개선
+- **브랜드 복합 인덱스**: `(brand_id, sort_col)` 복합 인덱스로 브랜드 필터 쿼리에서 filesort 완전 제거. 모든 브랜드 쿼리 0.05~0.8ms
+- **읽기 최적화 우선**: like_count 갱신 시 인덱스 2개 갱신 비용을 수용하는 대신, 읽기 QPS가 압도적으로 높은 환경에서 브랜드 쿼리 성능을 극대화
+
+### 6.3 한계
+
+- 목록 조회: p95·p99가 목표 미달 — 인덱스로 쿼리는 ms 단위로 빨라졌으나, 요청당 DB 다회 접근(Product + Brand + Like)에 따른 커넥션 풀 경합이 잔존
+- 상세 조회: 인덱스 적용 전후 쿼리 성능 차이 없음 — 원래 PK 조회로 빠르지만, 200VU 집중 시 커넥션 풀(40) 고갈로 에러율 9.0%
+
+### 6.4 후속 조치
+
+- 캐싱으로 DB 접근 횟수 자체를 줄여, 같은 커넥션 풀로 더 많은 동시 요청 처리 필요 → [캐싱 적용 보고서](performance-report-cache.md)
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index f1024a3dd..3c329ed77 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -6,6 +6,7 @@
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
+import jakarta.persistence.Index;
import jakarta.persistence.Table;
import com.loopers.domain.BaseEntity;
@@ -18,7 +19,14 @@
import lombok.NoArgsConstructor;
@Entity
-@Table(name = "product")
+@Table(name = "product", indexes = {
+ @Index(name = "idx_product_like_count", columnList = "like_count DESC"),
+ @Index(name = "idx_product_created_at", columnList = "created_at DESC"),
+ @Index(name = "idx_product_price", columnList = "price ASC"),
+ @Index(name = "idx_product_brand_like_count", columnList = "brand_id, like_count DESC"),
+ @Index(name = "idx_product_brand_created_at", columnList = "brand_id, created_at DESC"),
+ @Index(name = "idx_product_brand_price", columnList = "brand_id, price ASC")
+})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Product extends BaseEntity {
@@ -62,6 +70,14 @@ public void deductStock(Long quantity) {
this.stock.deduct(quantity);
}
+ /**
+ * 좋아요 수를 delta만큼 조정한다. 캐시 Write-Through 전용.
+ * DB의 좋아요 수는 별도의 atomic 쿼리로 관리된다.
+ */
+ public void adjustLikeCount(int delta) {
+ this.likeCount = Math.max(0, this.likeCount + delta);
+ }
+
public void update(ModifyProduct product) {
if (isDeleted()) {
throw new CoreException(ErrorType.ALREADY_DELETED_PRODUCT);
From 9a3df29ded3dd233be6218a2b708f887ae0a8c01 Mon Sep 17 00:00:00 2001
From: leeedohyun
Date: Thu, 12 Mar 2026 01:38:49 +0900
Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EB=B2=94=EC=9A=A9=20=EC=BA=90?=
=?UTF-8?q?=EC=8B=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EC=83=81?=
=?UTF-8?q?=ED=99=94=20=EB=B0=8F=20Redis=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
.../loopers/domain/shared/cache/CacheKey.java | 47 ++++++
.../domain/shared/cache/CacheRepository.java | 47 ++++++
.../domain/shared/cache/CacheType.java | 33 ++++
.../shared/cache/RedisCacheRepository.java | 98 ++++++++++++
.../domain/shared/cache/CacheKeyTest.java | 72 +++++++++
.../RedisCacheRepositoryIntegrationTest.java | 149 ++++++++++++++++++
.../RedisTestContainersConfig.java | 3 -
7 files changed, 446 insertions(+), 3 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheKey.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheType.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheKey.java b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheKey.java
new file mode 100644
index 000000000..058ad3660
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheKey.java
@@ -0,0 +1,47 @@
+package com.loopers.domain.shared.cache;
+
+/**
+ * 구분자 기반의 범용 캐시 키 생성기.
+ *
+ * 고정 prefix와 가변 세그먼트를 {@code :}로 결합하여 캐시 키를 생성한다.
+ *
+ * {@code
+ * CacheKey key = new CacheKey("product", "v1", "detail");
+ * key.of(123); // "product:v1:detail:123"
+ * key.pattern(); // "product:v1:detail:*"
+ * }
+ */
+public class CacheKey {
+
+ private static final String DELIMITER = ":";
+ private static final String WILDCARD = "*";
+
+ private final String prefix;
+
+ public CacheKey(String... prefixSegments) {
+ this.prefix = String.join(DELIMITER, prefixSegments);
+ }
+
+ /**
+ * prefix에 세그먼트를 이어 붙여 캐시 키를 생성한다.
+ *
+ * @param segments 가변 세그먼트
+ * @return 완성된 캐시 키
+ */
+ public String of(Object... segments) {
+ StringBuilder sb = new StringBuilder(prefix);
+ for (Object segment : segments) {
+ sb.append(DELIMITER).append(segment);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * prefix 하위의 모든 키를 매칭하는 와일드카드 패턴을 반환한다.
+ *
+ * @return 와일드카드 패턴 (예: {@code "product:v1:detail:*"})
+ */
+ public String pattern() {
+ return prefix + DELIMITER + WILDCARD;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
new file mode 100644
index 000000000..4710aeb9d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
@@ -0,0 +1,47 @@
+package com.loopers.domain.shared.cache;
+
+import java.time.Duration;
+
+/**
+ * 키-값 기반 캐시 저장소 인터페이스.
+ *
+ * 특정 캐시 구현(Redis, Caffeine 등)에 의존하지 않는 범용 인터페이스로,
+ * 타입 정보는 메서드 호출 시 {@link CacheType}을 통해 전달한다.
+ *
+ * @see CacheType
+ */
+public interface CacheRepository {
+
+ /**
+ * 캐시에 값을 저장한다. TTL 없이 저장되며, 명시적으로 삭제하기 전까지 유지된다.
+ *
+ * @param key 캐시 키
+ * @param value 저장할 값
+ */
+ void put(String key, T value);
+
+ /**
+ * 캐시에 값을 저장하며, 지정한 TTL이 만료되면 자동으로 삭제된다.
+ *
+ * @param key 캐시 키
+ * @param value 저장할 값
+ * @param ttl 만료 시간
+ */
+ void put(String key, T value, Duration ttl);
+
+ /**
+ * 캐시에서 값을 조회한다.
+ *
+ * @param key 캐시 키
+ * @param type 역직렬화 대상 타입 토큰
+ * @return 캐시된 값, 캐시 미스 또는 역직렬화 실패 시 {@code null}
+ */
+ T get(String key, CacheType type);
+
+ /**
+ * 패턴에 매칭되는 캐시 키를 일괄 삭제한다.
+ *
+ * @param keyPattern 삭제할 키 패턴 (예: {@code "product:list:*"})
+ */
+ void evict(String keyPattern);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheType.java b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheType.java
new file mode 100644
index 000000000..e6297537a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheType.java
@@ -0,0 +1,33 @@
+package com.loopers.domain.shared.cache;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+import lombok.Getter;
+
+/**
+ * 런타임에 제네릭 타입 정보를 보존하기 위한 Super Type Token.
+ *
+ * Java의 타입 소거로 인해 {@code Class}만으로는 {@code Page} 같은
+ * 파라미터화된 타입을 표현할 수 없다. 익명 서브클래스를 생성하면 JVM이 상속 관계의
+ * 제네릭 정보를 바이트코드에 보존하므로, 이를 리플렉션으로 추출하여 역직렬화에 활용한다.
+ *
+ * {@code
+ * // 사용 예시
+ * private static final CacheType> PAGE_TYPE = new CacheType<>() {};
+ * cacheRepository.get(key, PAGE_TYPE);
+ * }
+ *
+ * @param 보존할 대상 타입
+ * @see CacheRepository
+ */
+@Getter
+public abstract class CacheType {
+
+ private final Type type;
+
+ protected CacheType() {
+ Type superClass = getClass().getGenericSuperclass();
+ this.type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
new file mode 100644
index 000000000..b9a8f1999
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
@@ -0,0 +1,98 @@
+package com.loopers.infrastructure.shared.cache;
+
+import java.time.Duration;
+import java.util.Set;
+
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Repository;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.domain.shared.cache.CacheRepository;
+import com.loopers.domain.shared.cache.CacheType;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Redis 기반 {@link CacheRepository} 구현체.
+ *
+ * 값은 JSON 문자열로 직렬화하여 Redis에 저장하며,
+ * 역직렬화 시 {@link CacheType}의 타입 정보를 활용한다.
+ *
+ * @see CacheRepository
+ * @see CacheType
+ */
+@Slf4j
+@Repository
+public class RedisCacheRepository implements CacheRepository {
+
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 주입받은 ObjectMapper를 복사하여 캐시 전용으로 구성한다.
+ *
+ * 글로벌 ObjectMapper의 설정을 오염시키지 않기 위해 {@code copy()}로 별도 인스턴스를 생성하며,
+ * getter/setter 없이 필드 직접 접근으로 직렬화하도록 visibility를 재설정한다.
+ * 이는 Lombok {@code @Getter}만 사용하고 setter가 없는 Entity/VO를 안전하게 처리하기 위함이다.
+ */
+ public RedisCacheRepository(RedisTemplate redisTemplate, ObjectMapper objectMapper) {
+ this.redisTemplate = redisTemplate;
+ this.objectMapper = objectMapper.copy()
+ .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE)
+ .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
+ }
+
+ @Override
+ public void put(String key, T value) {
+ try {
+ String json = objectMapper.writeValueAsString(value);
+ redisTemplate.opsForValue().set(key, json);
+ log.debug("Cache PUT — key={}", key);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 직렬화 실패, key={}", key, e);
+ }
+ }
+
+ @Override
+ public void put(String key, T value, Duration ttl) {
+ try {
+ String json = objectMapper.writeValueAsString(value);
+ redisTemplate.opsForValue().set(key, json, ttl);
+ log.debug("Cache PUT — key={}, ttl={}", key, ttl);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 직렬화 실패, key={}", key, e);
+ }
+ }
+
+ @Override
+ public T get(String key, CacheType type) {
+ String json = redisTemplate.opsForValue().get(key);
+ if (json == null) {
+ log.debug("Cache MISS — key={}", key);
+ return null;
+ }
+ try {
+ JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType());
+ T result = objectMapper.readValue(json, javaType);
+ log.debug("Cache HIT — key={}", key);
+ return result;
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 역직렬화 실패, key={}", key, e);
+ redisTemplate.delete(key);
+ return null;
+ }
+ }
+
+ @Override
+ public void evict(String keyPattern) {
+ Set keys = redisTemplate.keys(keyPattern);
+ if (!keys.isEmpty()) {
+ redisTemplate.delete(keys);
+ log.debug("Cache EVICT — pattern={}, deletedKeys={}", keyPattern, keys.size());
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java
new file mode 100644
index 000000000..9ca26e720
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java
@@ -0,0 +1,72 @@
+package com.loopers.domain.shared.cache;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class CacheKeyTest {
+
+ @DisplayName("캐시 키를 생성할 때,")
+ @Nested
+ class Of {
+
+ @DisplayName("세그먼트를 구분자로 결합하여 키를 반환한다.")
+ @Test
+ void joinsSegmentsWithDelimiter() {
+ // arrange
+ CacheKey key = new CacheKey("product", "detail", "v1");
+
+ // act
+ String result = key.of(123);
+
+ // assert
+ assertThat(result).isEqualTo("product:detail:v1:123");
+ }
+
+ @DisplayName("여러 세그먼트를 전달하면 모두 결합한다.")
+ @Test
+ void joinsMultipleSegments() {
+ // arrange
+ CacheKey key = new CacheKey("product", "list", "v1");
+
+ // act
+ String result = key.of("all", "LATEST", 1, 20);
+
+ // assert
+ assertThat(result).isEqualTo("product:list:v1:all:LATEST:1:20");
+ }
+
+ @DisplayName("세그먼트 없이 호출하면 prefix만 반환한다.")
+ @Test
+ void returnsPrefixOnly_whenNoSegments() {
+ // arrange
+ CacheKey key = new CacheKey("product", "detail", "v1");
+
+ // act
+ String result = key.of();
+
+ // assert
+ assertThat(result).isEqualTo("product:detail:v1");
+ }
+ }
+
+ @DisplayName("와일드카드 패턴을 생성할 때,")
+ @Nested
+ class Pattern {
+
+ @DisplayName("prefix 뒤에 :* 를 붙여 반환한다.")
+ @Test
+ void appendsWildcard() {
+ // arrange
+ CacheKey key = new CacheKey("product", "list", "v1");
+
+ // act
+ String result = key.pattern();
+
+ // assert
+ assertThat(result).isEqualTo("product:list:v1:*");
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java
new file mode 100644
index 000000000..9f66bbd99
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java
@@ -0,0 +1,149 @@
+package com.loopers.infrastructure.shared.cache;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.time.Duration;
+import java.util.List;
+
+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 com.loopers.domain.shared.cache.CacheRepository;
+import com.loopers.domain.shared.cache.CacheType;
+import com.loopers.support.page.Page;
+import com.loopers.utils.RedisCleanUp;
+
+@SpringBootTest
+class RedisCacheRepositoryIntegrationTest {
+
+ private static final CacheType STRING_TYPE = new CacheType<>() {};
+ private static final CacheType> PAGE_TYPE = new CacheType<>() {};
+
+ @Autowired
+ private CacheRepository cacheRepository;
+
+ @Autowired
+ private RedisCleanUp redisCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ redisCleanUp.truncateAll();
+ }
+
+ @DisplayName("캐시에 값을 저장하고 조회할 때,")
+ @Nested
+ class PutAndGet {
+
+ @DisplayName("단순 타입을 저장하면, 동일한 값이 조회된다.")
+ @Test
+ void returnsStoredValue_whenSimpleTypePut() {
+ // arrange
+ String key = "test:simple";
+ String value = "hello";
+
+ // act
+ cacheRepository.put(key, value);
+ String result = cacheRepository.get(key, STRING_TYPE);
+
+ // assert
+ assertThat(result).isEqualTo("hello");
+ }
+
+ @DisplayName("파라미터화된 타입을 저장하면, 타입 정보가 보존되어 조회된다.")
+ @Test
+ void returnsStoredValue_whenParameterizedTypePut() {
+ // arrange
+ String key = "test:page";
+ Page value = new Page<>(
+ List.of(new TestItem(1L, "item1"), new TestItem(2L, "item2")),
+ true
+ );
+
+ // act
+ cacheRepository.put(key, value);
+ Page result = cacheRepository.get(key, PAGE_TYPE);
+
+ // assert
+ assertAll(
+ () -> assertThat(result.content()).hasSize(2),
+ () -> assertThat(result.content().get(0).name()).isEqualTo("item1"),
+ () -> assertThat(result.hasNext()).isTrue()
+ );
+ }
+
+ @DisplayName("존재하지 않는 키를 조회하면, null이 반환된다.")
+ @Test
+ void returnsNull_whenKeyDoesNotExist() {
+ // act
+ String result = cacheRepository.get("test:nonexistent", STRING_TYPE);
+
+ // assert
+ assertThat(result).isNull();
+ }
+ }
+
+ @DisplayName("TTL을 지정하여 저장할 때,")
+ @Nested
+ class PutWithTtl {
+
+ @DisplayName("TTL이 만료되면, null이 반환된다.")
+ @Test
+ void returnsNull_whenTtlExpired() throws InterruptedException {
+ // arrange
+ String key = "test:ttl";
+ cacheRepository.put(key, "expiring", Duration.ofSeconds(1));
+
+ // act
+ Thread.sleep(1500);
+ String result = cacheRepository.get(key, STRING_TYPE);
+
+ // assert
+ assertThat(result).isNull();
+ }
+
+ @DisplayName("TTL이 만료되기 전이면, 값이 조회된다.")
+ @Test
+ void returnsValue_whenTtlNotExpired() {
+ // arrange
+ String key = "test:ttl-alive";
+ cacheRepository.put(key, "still-alive", Duration.ofMinutes(1));
+
+ // act
+ String result = cacheRepository.get(key, STRING_TYPE);
+
+ // assert
+ assertThat(result).isEqualTo("still-alive");
+ }
+ }
+
+ @DisplayName("캐시를 삭제할 때,")
+ @Nested
+ class Evict {
+
+ @DisplayName("패턴에 매칭되는 키가 모두 삭제된다.")
+ @Test
+ void deletesAllMatchingKeys_whenPatternProvided() {
+ // arrange
+ cacheRepository.put("product:list:1", "a");
+ cacheRepository.put("product:list:2", "b");
+ cacheRepository.put("order:list:1", "c");
+
+ // act
+ cacheRepository.evict("product:list:*");
+
+ // assert
+ assertAll(
+ () -> assertThat(cacheRepository.get("product:list:1", STRING_TYPE)).isNull(),
+ () -> assertThat(cacheRepository.get("product:list:2", STRING_TYPE)).isNull(),
+ () -> assertThat(cacheRepository.get("order:list:1", STRING_TYPE)).isEqualTo("c")
+ );
+ }
+ }
+
+ record TestItem(Long id, String name) {}
+}
diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
index 35bf94f06..83edd0458 100644
--- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
+++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
@@ -10,9 +10,6 @@ public class RedisTestContainersConfig {
static {
redisContainer.start();
- }
-
- public RedisTestContainersConfig() {
System.setProperty("datasource.redis.database", "0");
System.setProperty("datasource.redis.master.host", redisContainer.getHost());
System.setProperty("datasource.redis.master.port", String.valueOf(redisContainer.getFirstMappedPort()));
From 5b3b36a44585785fadf0f016efbefeb7a8576bba Mon Sep 17 00:00:00 2001
From: leeedohyun
Date: Thu, 12 Mar 2026 18:50:14 +0900
Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=EC=97=90=20Redis=20=EC=BA=90=EC=8B=B1=20=EC=A0=81?=
=?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20ProductReader/ProductWriter=20=EB=B6=84?=
=?UTF-8?q?=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
.docs/performance/performance-report-cache.md | 367 ++++++++++++++++++
.../application/like/LikeProductUseCase.java | 4 +-
.../like/UnlikeProductUseCase.java | 4 +-
.../product/DeleteProductUseCase.java | 6 +-
.../product/ProductDetailAssembler.java | 2 +
.../ReadActiveProductDetailUseCase.java | 11 +-
.../product/ReadActiveProductsUseCase.java | 9 +-
.../product/UpdateProductUseCase.java | 6 +-
.../com/loopers/domain/product/Product.java | 4 -
.../domain/product/ProductCacheConstants.java | 26 ++
.../loopers/domain/product/ProductReader.java | 170 ++++++++
.../domain/product/ProductService.java | 4 +-
.../loopers/domain/product/ProductWriter.java | 89 +++++
.../domain/shared/cache/CacheRepository.java | 19 +
.../shared/cache/RedisCacheRepository.java | 52 +++
.../domain/product/ProductFixture.java | 12 +
.../domain/product/ProductReaderTest.java | 156 ++++++++
.../domain/product/ProductWriterTest.java | 156 ++++++++
.../RedisCacheRepositoryIntegrationTest.java | 100 ++++-
.../java/com/loopers/support/BaseE2ETest.java | 7 +-
20 files changed, 1174 insertions(+), 30 deletions(-)
create mode 100644 .docs/performance/performance-report-cache.md
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheConstants.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWriter.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductFixture.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReaderTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductWriterTest.java
diff --git a/.docs/performance/performance-report-cache.md b/.docs/performance/performance-report-cache.md
new file mode 100644
index 000000000..15c59bd09
--- /dev/null
+++ b/.docs/performance/performance-report-cache.md
@@ -0,0 +1,367 @@
+# 상품 조회 API 성능 개선 보고서 — Redis 캐싱
+
+> 테스트 환경, 데이터 분포, 개선 전 측정값은 [performance-base.md](performance-base.md) 참조
+> 인덱스 최적화 내용은 [performance-report-index.md](performance-report-index.md) 참조
+
+## 1. 개요
+
+인덱스 적용 후에도 200VU 부하에서 상세 조회 에러율 9.0%(커넥션 풀 고갈)가 발생한다. 캐싱으로 DB 접근 횟수를 줄여 커넥션 풀 경합을 완화한다.
+
+### 1.1 배경
+
+인덱스 적용으로 단일 쿼리는 ms 단위로 빨라졌으나, 요청당 DB 다회 접근이 병목으로 남았다.
+
+- **목록 조회**: 요청당 DB 3회 (Product 목록 + Brand 배치 + Like 배치)
+- **상세 조회**: 요청당 DB 3회 (Product + Brand + Like)
+- HikariCP 커넥션 풀: 40개, MySQL max_connections: 151
+- 200VU 집중 시 커넥션 풀 고갈 → `Unable to acquire JDBC Connection` 에러
+
+---
+
+## 2. 목표 설정
+
+캐싱으로 Product DB 조회를 생략하여, 요청당 DB 접근 횟수를 줄이고 커넥션 풀 여유를 확보한다.
+
+| 지표 | 인덱스만 (목록 / 상세) | 목표 | 산출 근거 |
+|------|----------------------|------|-----------|
+| RPS | 192/s / 23/s | **목록 200/s 이상** | Product 캐시 HIT 시 DB 1회 생략 → 커넥션 반환 빨라짐 |
+| 에러율 | 0.1% / 9.0% | **상세 5% 미만** | DB 접근 3회→2회로 커넥션 점유 시간 감소 |
+| avg | 455ms / 3s | **목록 400ms 이하** | 캐시 HIT로 추가 개선 기대 |
+
+---
+
+## 3. 병목 분석
+
+인덱스 적용 후 잔존 병목:
+
+```
+[목록 조회 요청]
+ 1. Product 목록 DB 조회 (인덱스 스캔, ~0.1ms) ← 캐시 대상
+ 2. Brand 배치 DB 조회 (IN 쿼리)
+ 3. Like 배치 DB 조회 (비회원은 early return)
+ → 요청당 DB 커넥션 2~3회 점유
+
+[상세 조회 요청]
+ 1. Product 단건 DB 조회 (PK, ~0.0002ms) ← 캐시 대상
+ 2. Brand 단건 DB 조회 (PK)
+ 3. Like 존재 여부 DB 조회 (비회원은 early return)
+ → 요청당 DB 커넥션 2~3회 점유
+```
+
+- Product 조회는 이미 인덱스/PK로 빠르지만, **DB 커넥션을 점유한다는 사실 자체**가 병목
+- 200VU × 요청당 2~3회 DB 접근 = 400~600 동시 커넥션 수요 vs 풀 40개
+
+---
+
+## 4. 개선 전략
+
+### 캐싱 방식
+
+| 항목 | 설정 |
+|------|------|
+| 패턴 | Cache-Aside (Look Aside) |
+| 저장소 | Redis (String, JSON 직렬화) |
+| 목록 캐시 대상 | page 0~2 (앞쪽 3페이지) |
+| 목록 TTL | 1분 |
+| 상세 캐시 대상 | 전체 상품 |
+| 상세 TTL | 5분 |
+
+### 캐시 키 설계
+
+```
+목록: product:list:{brandId|all}:{sortType}:{page}:{size}
+상세: product:detail:{productId}
+```
+
+### 설계 의도
+
+- **캐시 HIT 시 DB 커넥션을 점유하지 않음** → 커넥션 풀 여유 확보 → 캐시 MISS 요청에 커넥션 할당 가능
+- 목록은 page 0~2만 캐시: 대부분의 트래픽이 앞쪽 페이지에 집중되는 점 활용
+- 상세는 전체 캐시: 롱테일 분포(상위 20% 상품에 80% 트래픽)에서 높은 HIT율 기대
+- JSON 직렬화: Entity를 그대로 캐시하되, 캐시 전용 ObjectMapper로 필드 직접 접근
+
+---
+
+## 5. 적용 결과
+
+### 5.1 부하 테스트 — 상품 목록 조회 (200 VU)
+
+| 지표 | 인덱스만 | 캐시 적용 후 | 변화 |
+|------|---------|-------------|------|
+| RPS | 191.91/s | **213.31/s** | +11% |
+| 에러율 | 0.1% | **0.0%** | -100% |
+| avg | 455ms | **409ms** | -10% |
+| med | 168ms | **149ms** | -11% |
+| p90 | 1s | **926ms** | -7% |
+| p95 | 2s | **2s** | - |
+| p99 | 4s | **4s** | - |
+| max | 7s | **8s** | - |
+
+### 5.2 부하 테스트 — 상품 상세 조회 (200 VU)
+
+| 지표 | 인덱스만 | 캐시 적용 후 | 변화 |
+|------|---------|-------------|------|
+| RPS | 23.47/s | **22.53/s** | -4% |
+| 에러율 | 9.0% | **2.1%** | -77% |
+| avg | 3s | **3s** | - |
+| med | 3s | **3s** | - |
+| p90 | 7s | **7s** | - |
+| p95 | 7s | **8s** | - |
+| p99 | 8s | **9s** | - |
+| max | 8s | **11s** | - |
+
+---
+
+## 6. 분석 및 결론
+
+### 6.1 목표 달성 여부
+
+| 지표 | 목표 | 결과 | 달성 |
+|------|------|------|------|
+| 목록 RPS | 200/s 이상 | 213.31/s | O |
+| 상세 에러율 | 5% 미만 | 2.1% | O |
+| 목록 avg | 400ms 이하 | 409ms | X (근접) |
+
+### 6.2 개선 효과
+
+- **목록 조회 RPS 11% 향상** (192 → 213/s): 캐시 HIT 시 Product DB 조회 생략 → 커넥션 반환 가속, 200/s 목표 달성
+- **목록 조회 avg 10% 감소** (455ms → 409ms): 캐시 HIT 요청의 응답 시간 단축
+- **상세 조회 에러율 77% 감소** (9.0% → 2.1%): DB 접근 3회→2회로 커넥션 점유 감소 → 풀 고갈 빈도 대폭 완화
+
+### 6.3 한계
+
+- **목록 p95 이상 개선 미미**: 캐시 MISS 요청(page 3+, 신규 정렬 조합)은 여전히 DB 다회 접근. 캐시 HIT/MISS 편차가 tail latency에 반영
+- **상세 에러율 2.1% 잔존**: Product 1건만 캐시하고 Brand·Like는 매번 DB 접근. 200VU 집중 시 여전히 커넥션 풀 경합 발생
+- **상세 RPS 거의 변화 없음** (23→22/s): 에러 요청도 커넥션 대기 시간을 소모하므로, 에러율이 줄어도 전체 처리량은 커넥션 풀 크기에 제약
+
+### 6.4 후속 조치
+
+- Brand 로컬 캐시(ConcurrentHashMap) 적용으로 DB 접근 추가 감소 검토
+- 목록 캐시를 ID 리스트만 저장하는 구조로 변경하여, 상세 캐시 재사용 및 저장 공간 절약 검토
+
+---
+
+## 7. 캐시 쓰기 전략 및 TTL 재설정
+
+### 7.1 캐시 쓰기 전략 (ProductWriter)
+
+Read(Look-Aside)만 있던 캐시에 Write 전략을 추가하여, TTL 만료 전에도 데이터 정합성을 확보한다.
+
+| 이벤트 | 목록 캐시 (`product:list:*`) | 상세 캐시 (`product:detail:{id}`) |
+|--------|---------------------------|--------------------------------|
+| 상품 등록 | TTL 위임 | — |
+| 상품 수정 | — | 해당 키 overwrite |
+| 상품 삭제 | 전체 evict | 해당 키 evict |
+| 좋아요/취소 | TTL 위임 | Write-Through (miss면 무시) |
+
+- 상품 등록 시 목록 캐시를 evict하지 않는다. 새 상품이 등록되어도 기존 ID 리스트는 유효한 조회 결과이며, TTL 만료(1분) 후 자연 갱신된다. 어드민 전용으로 빈도가 극히 낮아 1분 지연은 허용 가능하다.
+- 상품 수정 시 목록 캐시를 evict하지 않는다. 목록 캐시에는 ID 리스트만 저장되어 있으므로 상품 데이터 변경이 ID 리스트에 영향을 주지 않는다. 상세 캐시를 overwrite하면 목록 조회의 MGET 시점에도 최신 데이터가 반영된다.
+- 상품 삭제 시 목록 캐시를 전체 evict한다. 삭제된 상품 ID가 목록에 남으면 페이지 건수가 줄어드는 부작용이 있으므로 즉시 무효화한다.
+- 좋아요 Write-Through는 Redis GET → 수정 → PUT이므로 DB 추가 부하 없음
+
+### 7.2 TTL 재설정
+
+| 캐시 | 이전 TTL | 변경 TTL | 근거 |
+|------|---------|---------|------|
+| 목록 | 1분 | **1분 (유지)** | 1min × 213 RPS = ~12,780건 DB 조회 보호. likeCount 지연 최대 1분으로 허용 가능 |
+| 상세 | 5분 | **5분 (유지)** | CUD 시 즉시 evict + likeCount Write-Through로 정합성 확보. TTL은 안전망 역할. 5min × 23 RPS = ~6,900건 보호 |
+
+> 이 쓰기 전략은 이후 섹션 8에서 목록 캐시를 ID 리스트 구조로 변경한 뒤에도 동일하게 적용된다.
+
+### 7.3 설계 원칙
+
+**짧은 TTL + 적극적 캐시 갱신** 전략을 채택:
+
+- 긴 TTL에 의존하여 정합성을 포기하는 대신, CUD/좋아요 시 즉시 캐시를 갱신/무효화
+- TTL은 evict 실패나 예외 상황에 대한 최종 방어선으로만 사용
+- TTL 산출 기준: 부하테스트 RPS × TTL 초 = 보호 요청 수. 최소 수천 건 이상이면 유의미
+
+---
+
+## 8. 목록 캐시 구조 개선 — ID 리스트 캐싱
+
+### 8.1 개요
+
+기존 목록 캐시 키는 `product:list:{brandId|all}:{sortType}:{page}:{size}` 형태로, 브랜드 × 정렬 × 페이지 × 사이즈 조합 수만큼 키가 생긴다. 조합이 늘어날수록 캐시 HIT 확률이 낮아지고, 같은 상품 데이터가 여러 키에 중복 저장되며, 갱신 시 목록 캐시 전체를 evict해야 하는 비효율이 있다.
+
+이를 개선하기 위해 **목록 캐시에는 ID 리스트만 저장**하고, 상품 데이터는 **개별 상세 캐시(`product:{id}`)를 조회**하는 구조로 변경했다. 목록 조회 시 캐시에 저장된 상품 데이터가 상세 조회에서도 재사용되므로 전체 HIT율이 높아진다. 이하 기존 `Page` 통째로 캐싱하는 방식을 "통캐싱"으로 표기한다.
+
+### 8.2 Baseline 측정 (통캐싱)
+
+> 200VU 부하테스트, 3분간 Redis readonly replica 모니터링
+> 테스트 시나리오는 정렬 3종 × page 0~2로 조합이 제한적이므로 HIT율이 높게 측정된다. 실환경에서 브랜드 필터·페이지가 다양해지면 키 수가 급증하여 HIT율은 낮아진다.
+
+| 항목 | 값 |
+|------|-----|
+| 메모리 사용량 | 2.20 ~ 2.24 MB |
+| 피크 메모리 | 6.18 MB |
+| 키 개수 | 13개 |
+| Cache Hit (3분 누적) | 27,821 |
+| Cache Miss (3분 누적) | 106 |
+| **Hit Rate** | **99.62%** |
+
+### 8.3 구간별 성능 프로파일링
+
+ID 리스트 캐싱 적용 후 목록 전용 부하테스트에서 RPS가 36% 하락했다(213 → 136/s). 원인 파악을 위해 단일 API 호출의 구간별 소요시간을 `System.nanoTime()`으로 측정했다.
+
+#### 측정 구간
+
+```
+[UseCase] ─── 전체 시간
+ ├─ 브랜드 검증
+ ├─ [ProductReader] ─── 캐시 조회 + DB fallback
+ │ ├─ Redis GET (ID 리스트 or Page)
+ │ ├─ Redis MGET (개별 상세 캐시, ID 리스트 방식만)
+ │ ├─ DB fallback (캐시 미스 시)
+ │ └─ Redis PUT/MPUT (캐시 저장)
+ └─ [Assembler] ─── 결과 조합
+ ├─ DB 브랜드 조회 (IN 쿼리)
+ ├─ DB 좋아요 조회 (IN 쿼리)
+ └─ 결과 조립 (stream map)
+```
+
+#### ID 리스트 방식 — 안정 상태 (4번째 요청, 55ms)
+
+| 구간 | 소요 시간 | 비중 |
+|------|----------|------|
+| Redis GET (ID 리스트) | 24ms | 44% |
+| Redis MGET (20건) | 8ms | 15% |
+| DB 브랜드 조회 | 7ms | 13% |
+| DB 좋아요 조회 | 7ms | 13% |
+| 결과 조립 | 0.05ms | - |
+
+RedisCacheRepository 내부 분리 측정:
+
+| | Redis 왕복 | 역직렬화 | 데이터 크기 |
+|---|-----------|---------|-----------|
+| GET (ID 리스트) | 14ms | 2ms | 84 bytes |
+| MGET (20건) | 3ms | 4ms | 6,060 bytes |
+
+#### 기존 통캐싱 방식 — 안정 상태 (3번째 요청, 19ms)
+
+| 구간 | 소요 시간 | 비중 |
+|------|----------|------|
+| Redis GET (Page\) | 8ms | 42% |
+| DB 브랜드 조회 | 3ms | 17% |
+| DB 좋아요 조회 | 3ms | 18% |
+| 결과 조립 | 0.05ms | - |
+
+RedisCacheRepository 내부 분리 측정:
+
+| | Redis 왕복 | 역직렬화 | 데이터 크기 |
+|---|-----------|---------|-----------|
+| GET (Page\) | 3.5ms | 3.9ms | 6,109 bytes |
+
+#### 안정 상태 비교
+
+| | 통캐싱 | ID 리스트 | 차이 |
+|---|----------|---------|------|
+| Redis 왕복 | 3.5ms (GET 1회) | 17ms (GET + MGET 2회) | **4.9배** |
+| 역직렬화 | 3.9ms (6KB 1건) | 6ms (84B + 6KB 20건) | 1.5배 |
+| **ProductReader 합계** | **8ms** | **32ms** | **4배** |
+| **UseCase 전체** | **19ms** | **55ms** | **2.9배** |
+
+안정 상태 기준, 주된 비용 차이는 **Redis 왕복 횟수(1회 vs 2회)**이다. 역직렬화는 6ms vs 4ms로 유의미한 차이가 아니다.
+
+### 8.4 캐시 스탬피드 분석
+
+RPS 36% 하락의 원인을 200VU 부하테스트 3분간 `redis-cli INFO stats`의 `instantaneous_ops_per_sec`을 1초 간격으로 모니터링하여 검증했다.
+
+| | ID 리스트 | 통캐싱 | 배율 |
+|---|---------|---------|------|
+| **피크 ops/sec** | **969** | **54** | **18배** |
+| **HIT 구간 ops/sec** | 1~2 | 1~2 | 동일 |
+
+- 3번의 burst가 **1분 간격**으로 발생 — `LIST_TTL = 1분`과 일치
+- TTL 만료 시 200VU가 동시에 MISS → **캐시 스탬피드** 발생
+- ID 리스트 MISS: **MPUT(20건 파이프라인) + PUT(ID리스트)** → 969 ops/sec
+- 통캐싱 MISS: **PUT 1회** → 54 ops/sec
+- HIT 구간이 둘 다 1~2 ops/sec인 이유: DB(HikariCP 풀 40개)가 병목이라 완료 요청 수 자체가 적음
+
+**결론: RPS 36% 하락은 1분마다 발생하는 TTL 만료 시 스탬피드 규모가 18배 커지면서, Redis와 DB에 순간 부하가 집중되는 것이 주 원인.**
+
+### 8.5 캐시 스탬피드 락 적용
+
+`ReentrantLock` + `ConcurrentHashMap`으로 캐시 키 단위 락을 구현. Double-check 패턴으로 락 획득 후 캐시를 재확인하여, TTL 만료 시 **1개 스레드만 DB 조회 + 캐시 저장**을 수행한다.
+
+#### Redis ops/sec 비교 (락 전 → 후)
+
+**통캐싱:**
+
+| | 락 없음 | 락 적용 | 감소율 |
+|---|---------|---------|--------|
+| **전체 피크** | **54** | **13** | **-76%** |
+
+**ID 리스트:**
+
+| | 락 없음 | 락 적용 | 감소율 |
+|---|---------|---------|--------|
+| **전체 피크** | **969** | **147** | **-85%** |
+
+락 적용 후에도 ID 리스트 피크(147)가 통캐싱(13)의 11배인 이유: 락은 `listKey` 단위로만 적용되므로, 1개 스레드의 MISS 처리 시 **MPUT(20건) + PUT(ID리스트)** = 21+ Redis 명령은 구조적으로 변하지 않는다.
+
+#### 부하테스트 결과 (200VU, 3분)
+
+**락 적용 후 — 통캐싱 vs ID 리스트:**
+
+| 지표 | 통캐싱+락 | ID 리스트+락 | 차이 |
+|------|------------|-----------|------|
+| **RPS** | **216.74/s** | **193.13/s** | -11% |
+| **에러율** | 0.0% | 0.0% | 동일 |
+| **avg** | 402ms | 452ms | +12% |
+| **p95** | 1s | 2s | +100% |
+
+**락 적용 전후 비교:**
+
+| 지표 | 통캐싱 (전→후) | ID 리스트 (전→후) |
+|------|-------------|-----------------|
+| **RPS** | 213 → **217** (+2%) | 136 → **193** (+42%) |
+| **avg** | 409ms → **402ms** (-2%) | 645ms → **452ms** (-30%) |
+| **피크 ops/sec** | 54 → **13** | 969 → **147** |
+
+ID 리스트 방식이 락의 최대 수혜자(RPS +42%)이며, 락 적용 후 격차가 36% → 11%로 대폭 축소되었다.
+
+### 8.6 혼합 부하테스트 (목록 80% + 상세 20%)
+
+실제 사용자 패턴(목록 → 상품 클릭 → 상세)에서 ID 리스트 방식의 **상세 캐시 재사용 효과**를 검증한다.
+
+#### 테스트 구성
+
+- `listThenDetail` (80%): 목록 조회 → 응답에서 랜덤 1건 → 상세 조회
+- `detailOnly` (20%): 롱테일 분포(인기 상품 집중)로 상세 직접 조회
+- 부하 단계: 50 → 100 → 200 → 0 VU (3분)
+
+#### 결과
+
+| 지표 | ID 리스트 | 통캐싱 | 차이 |
+|------|---------|--------|------|
+| **RPS** | **682/s** | 573/s | **+19%** |
+| avg | **125ms** | 149ms | -16% |
+| med | **103ms** | 124ms | -17% |
+| list_duration (med) | **131ms** | 146ms | -10% |
+| detail_duration (med) | **51ms** | 66ms | **-23%** |
+| 에러율 | 0.0% | 0.0% | 동일 |
+
+#### 분석
+
+- **목록 전용에서는 통캐싱 11% 우세 → 혼합 부하에서는 ID 리스트 19% 우세로 역전**
+- 목록 조회 시 개별 상품을 `product:{id}` 캐시에 저장 → 이후 상세 조회가 이미 채워진 캐시를 HIT하면서 DB 조회 생략
+- detail_duration 23% 감소(51ms vs 66ms)가 핵심 증거
+- 목록도 10% 빠른 이유: 상세 캐시가 이미 존재하면 MGET으로 빠르게 조회 + DB 커넥션 풀 경합 감소
+
+### 8.7 최종 트레이드오프
+
+| 관점 | 통캐싱 | ID 리스트 |
+|------|---------|---------|
+| 단일 요청 성능 | **19ms** | 55ms |
+| Redis 왕복 횟수 | **1회** | 2회 |
+| 목록 전용 RPS (락 적용) | **217/s** | 193/s |
+| **혼합 부하 RPS (락 적용)** | 573/s | **682/s (+19%)** |
+| 혼합 detail_duration (med) | 66ms | **51ms (-23%)** |
+| 캐시 메모리 효율 | 정렬/필터 조합별 중복 저장 | **상세 캐시 단일 원본, 중복 제거** |
+| 상세 조회 캐시 재사용 | 불가 (목록/상세 별도) | **가능 (상세 캐시 공유)** |
+| 스탬피드 취약성 | 낮음 (MISS 시 PUT 1회) | 높음 (MISS 시 21+ 명령) → **락 필수** |
+
+### 8.8 결론
+
+목록 전용 부하에서는 통캐싱이 11% 우세하지만, 실제 사용자 패턴을 반영한 혼합 부하(목록 80% + 상세 20%)에서는 **ID 리스트 방식이 19% 우세**하다. 목록 조회 시 저장된 개별 상세 캐시가 상세 조회에서 재사용되는 것이 핵심 효과이며, 스탬피드 락 적용을 전제로 하면 ID 리스트 방식이 실환경에 더 적합한 전략이다.
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductUseCase.java
index 94a3f20f0..4d573215e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductUseCase.java
@@ -3,6 +3,7 @@
import org.springframework.transaction.annotation.Transactional;
import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductWriter;
import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.like.LikeService;
@@ -19,6 +20,7 @@ public class LikeProductUseCase {
private final LikeService likeService;
private final ProductService productService;
+ private final ProductWriter productWriter;
/**
* @param userId 사용자 ID
@@ -29,7 +31,7 @@ public void execute(Long userId, Long productId) {
productService.validateActiveProductExists(productId);
boolean created = likeService.like(userId, productId);
if (created) {
- productService.increaseLikeCount(productId);
+ productWriter.increaseLikeCount(productId);
}
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeProductUseCase.java
index 50d5d575e..9e399c599 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeProductUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeProductUseCase.java
@@ -3,6 +3,7 @@
import org.springframework.transaction.annotation.Transactional;
import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductWriter;
import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.like.LikeService;
@@ -19,6 +20,7 @@ public class UnlikeProductUseCase {
private final LikeService likeService;
private final ProductService productService;
+ private final ProductWriter productWriter;
/**
* @param userId 사용자 ID
@@ -29,7 +31,7 @@ public void execute(Long userId, Long productId) {
productService.validateActiveProductExists(productId);
boolean deleted = likeService.unlike(userId, productId);
if (deleted) {
- productService.decreaseLikeCount(productId);
+ productWriter.decreaseLikeCount(productId);
}
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java
index 3c1df2916..d468cecf8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java
@@ -4,7 +4,7 @@
import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.like.LikeService;
-import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductWriter;
import lombok.RequiredArgsConstructor;
@@ -18,7 +18,7 @@
@RequiredArgsConstructor
public class DeleteProductUseCase {
- private final ProductService productService;
+ private final ProductWriter productWriter;
private final LikeService likeService;
/**
@@ -26,7 +26,7 @@ public class DeleteProductUseCase {
*/
@Transactional
public void execute(Long productId) {
- boolean deleted = productService.delete(productId);
+ boolean deleted = productWriter.delete(productId);
if (!deleted) {
return;
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailAssembler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailAssembler.java
index d25cead84..a6525f4f0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailAssembler.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailAssembler.java
@@ -5,6 +5,7 @@
import java.util.Set;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
@@ -30,6 +31,7 @@ public class ProductDetailAssembler {
* @param userId 사용자 ID (비로그인 시 null)
* @return 브랜드 및 좋아요 정보가 포함된 상품 상세 목록
*/
+ @Transactional(readOnly = true)
public List assemble(List products, Long userId) {
List productIds = products.stream()
.map(Product::getId)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductDetailUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductDetailUseCase.java
index 829fbd3c1..3c637a84b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductDetailUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductDetailUseCase.java
@@ -1,13 +1,11 @@
package com.loopers.application.product;
-import org.springframework.transaction.annotation.Transactional;
-
-import com.loopers.domain.like.LikeService;
import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
+import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductReader;
import lombok.RequiredArgsConstructor;
@@ -20,7 +18,7 @@
@RequiredArgsConstructor
public class ReadActiveProductDetailUseCase {
- private final ProductService productService;
+ private final ProductReader productReader;
private final BrandService brandService;
private final LikeService likeService;
@@ -29,9 +27,8 @@ public class ReadActiveProductDetailUseCase {
* @param productId 상품 ID
* @return 상품 상세 정보 (브랜드, 좋아요 정보 포함)
*/
- @Transactional(readOnly = true)
public ProductDetail execute(Long userId, Long productId) {
- Product product = productService.getActiveProduct(productId);
+ Product product = productReader.readActiveProduct(productId);
Brand brand = brandService.getActiveBrand(product.getBrandId());
boolean liked = likeService.isLiked(userId, productId);
return ProductDetail.from(product, brand, liked);
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductsUseCase.java
index 74fcbbb5c..ef480f195 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductsUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ReadActiveProductsUseCase.java
@@ -3,12 +3,10 @@
import java.util.List;
import java.util.Objects;
-import org.springframework.transaction.annotation.Transactional;
-
import com.loopers.application.shared.annotation.UseCase;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductReader;
import com.loopers.domain.product.ProductSortType;
import com.loopers.support.page.Page;
import com.loopers.support.page.PageSize;
@@ -24,7 +22,7 @@
@RequiredArgsConstructor
public class ReadActiveProductsUseCase {
- private final ProductService productService;
+ private final ProductReader productReader;
private final BrandService brandService;
private final ProductDetailAssembler productDetailAssembler;
@@ -35,12 +33,11 @@ public class ReadActiveProductsUseCase {
* @param pageSize 페이지 크기
* @return 상품 상세 목록 페이지 (브랜드, 좋아요 정보 포함)
*/
- @Transactional(readOnly = true)
public Page execute(Long userId, Long brandId, ProductSortType sortType, PageSize pageSize) {
if (Objects.nonNull(brandId)) {
brandService.validateActiveBrandExists(brandId);
}
- Page products = productService.getActiveProducts(brandId, sortType, pageSize);
+ Page products = productReader.readActiveProducts(brandId, sortType, pageSize);
List results = productDetailAssembler.assemble(products.content(), userId);
return new Page<>(results, products.hasNext());
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java
index 33a442c91..7f1467891 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java
@@ -3,7 +3,7 @@
import org.springframework.transaction.annotation.Transactional;
import com.loopers.application.shared.annotation.UseCase;
-import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductWriter;
import lombok.RequiredArgsConstructor;
@@ -16,13 +16,13 @@
@RequiredArgsConstructor
public class UpdateProductUseCase {
- private final ProductService productService;
+ private final ProductWriter productWriter;
/**
* @param command 상품 수정 커맨드
*/
@Transactional
public void execute(ProductCommand.UpdateProductCommand command) {
- productService.update(command.toModifyProduct());
+ productWriter.update(command.toModifyProduct());
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index 3c329ed77..0f5b8a925 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -70,10 +70,6 @@ public void deductStock(Long quantity) {
this.stock.deduct(quantity);
}
- /**
- * 좋아요 수를 delta만큼 조정한다. 캐시 Write-Through 전용.
- * DB의 좋아요 수는 별도의 atomic 쿼리로 관리된다.
- */
public void adjustLikeCount(int delta) {
this.likeCount = Math.max(0, this.likeCount + delta);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheConstants.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheConstants.java
new file mode 100644
index 000000000..1fcd2747e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheConstants.java
@@ -0,0 +1,26 @@
+package com.loopers.domain.product;
+
+import java.time.Duration;
+
+import com.loopers.domain.shared.cache.CacheKey;
+import com.loopers.domain.shared.cache.CacheType;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * 상품 캐시에서 공유하는 키·TTL·타입 상수.
+ *
+ * @see ProductReader
+ * @see ProductWriter
+ */
+@UtilityClass
+final class ProductCacheConstants {
+
+ static final CacheKey LIST_KEY = new CacheKey("product", "list", "v1");
+ static final CacheKey DETAIL_KEY = new CacheKey("product", "detail", "v1");
+
+ static final Duration LIST_TTL = Duration.ofMinutes(1);
+ static final Duration DETAIL_TTL = Duration.ofMinutes(5);
+
+ static final CacheType PRODUCT_TYPE = new CacheType<>() {};
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java
new file mode 100644
index 000000000..324de1b82
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java
@@ -0,0 +1,170 @@
+package com.loopers.domain.product;
+
+import static com.loopers.domain.product.ProductCacheConstants.*;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import com.loopers.domain.shared.annotation.DomainService;
+import com.loopers.domain.shared.cache.CacheRepository;
+import com.loopers.domain.shared.cache.CacheType;
+import com.loopers.support.page.Page;
+import com.loopers.support.page.PageSize;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 캐시를 경유하여 상품을 조회하는 읽기 전용 도메인 서비스.
+ *
+ * 캐시 레이어링 전략을 사용한다:
+ *
+ * - 목록 캐시: ID 리스트 + hasNext만 저장
+ * - 상세 캐시: 상품 데이터의 단일 원본
+ *
+ * 목록 조회 시 ID 리스트 캐시 → 상세 일괄 조회 → 부분 미스 시 DB fallback.
+ */
+@DomainService
+@RequiredArgsConstructor
+public class ProductReader {
+
+ private static final CacheType ID_PAGE_TYPE = new CacheType<>() {};
+ private static final String ALL_BRAND = "all";
+ private static final int MAX_CACHEABLE_PAGE = 2;
+
+ private final CacheRepository cacheRepository;
+ private final ProductService productService;
+
+ /**
+ * 활성 상품 목록을 페이지 단위로 조회한다.
+ *
+ * @param brandId 브랜드 ID (null이면 전체 브랜드)
+ * @param sortType 정렬 기준
+ * @param pageSize 페이지 정보
+ * @return 활성 상품 목록 페이지
+ */
+ public Page readActiveProducts(Long brandId, ProductSortType sortType, PageSize pageSize) {
+ String listKey = buildListKey(brandId, sortType, pageSize);
+ ProductIdPage idPage = cacheRepository.get(listKey, ID_PAGE_TYPE);
+
+ if (Objects.nonNull(idPage)) {
+ return resolveProductsFromIdPage(idPage);
+ }
+
+ return fetchAndCacheProducts(brandId, sortType, pageSize);
+ }
+
+ /**
+ * 활성 상품 단건을 조회한다.
+ *
+ * @param productId 상품 ID
+ * @return 활성 상품
+ */
+ public Product readActiveProduct(Long productId) {
+ String key = DETAIL_KEY.of(productId);
+ Product cached = cacheRepository.get(key, PRODUCT_TYPE);
+
+ if (Objects.nonNull(cached)) {
+ return cached;
+ }
+
+ Product product = productService.getActiveProduct(productId);
+ cacheRepository.put(key, product, DETAIL_TTL);
+ return product;
+ }
+
+ private String buildListKey(Long brandId, ProductSortType sortType, PageSize pageSize) {
+ ProductSortType resolvedSortType = sortType != null ? sortType : ProductSortType.DEFAULT;
+ String brandSegment = Objects.nonNull(brandId) ? String.valueOf(brandId) : ALL_BRAND;
+ return LIST_KEY.of(brandSegment, resolvedSortType.name(), pageSize.page(), pageSize.size());
+ }
+
+ /**
+ * ID 리스트 캐시 HIT 시, 상품 상세를 일괄 조회하고 부분 미스를 처리한다.
+ */
+ private Page resolveProductsFromIdPage(ProductIdPage idPage) {
+ List ids = idPage.ids();
+ List cached = cacheRepository.multiGet(idPage.detailKeys(), PRODUCT_TYPE);
+
+ List missedIds = findMissedIds(ids, cached);
+ Map fetched = missedIds.isEmpty()
+ ? Collections.emptyMap()
+ : productService.getActiveProductsByIds(missedIds);
+
+ cacheProducts(fetched);
+
+ List products = mergeProducts(ids, cached, fetched);
+ return new Page<>(products, idPage.hasNext());
+ }
+
+ private List findMissedIds(List ids, List cached) {
+ List missedIds = new ArrayList<>();
+ for (int i = 0; i < cached.size(); i++) {
+ if (Objects.isNull(cached.get(i))) {
+ missedIds.add(ids.get(i));
+ }
+ }
+ return missedIds;
+ }
+
+ private List mergeProducts(List ids, List cached, Map fetched) {
+ List products = new ArrayList<>();
+ for (int i = 0; i < ids.size(); i++) {
+ Product product = cached.get(i);
+ if (Objects.isNull(product)) {
+ product = fetched.get(ids.get(i));
+ }
+ if (Objects.nonNull(product)) {
+ products.add(product);
+ }
+ }
+ return products;
+ }
+
+ /**
+ * 목록 캐시 MISS 시, DB에서 조회하고 양쪽 캐시에 저장한다.
+ */
+ private Page fetchAndCacheProducts(Long brandId, ProductSortType sortType, PageSize pageSize) {
+ Page products = productService.getActiveProducts(brandId, sortType, pageSize);
+
+ Map productMap = new HashMap<>();
+ products.content().forEach(product -> productMap.put(product.getId(), product));
+ cacheProducts(productMap);
+
+ if (isCacheablePage(pageSize.page())) {
+ List ids = products.content().stream().map(Product::getId).toList();
+ ProductIdPage idPage = new ProductIdPage(ids, products.hasNext());
+ cacheRepository.put(buildListKey(brandId, sortType, pageSize), idPage, LIST_TTL);
+ }
+
+ return products;
+ }
+
+ private void cacheProducts(Map products) {
+ if (products.isEmpty()) {
+ return;
+ }
+ Map entries = new HashMap<>();
+ products.forEach((id, product) -> entries.put(DETAIL_KEY.of(id), product));
+ cacheRepository.multiPut(entries, DETAIL_TTL);
+ }
+
+ private boolean isCacheablePage(int page) {
+ return page <= MAX_CACHEABLE_PAGE;
+ }
+
+ /**
+ * 목록 캐시에 저장되는 ID 리스트와 페이지 메타데이터.
+ */
+ record ProductIdPage(List ids, boolean hasNext) {
+
+ List detailKeys() {
+ return ids.stream()
+ .map(DETAIL_KEY::of)
+ .toList();
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
index 28c22944b..0e226b461 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -106,13 +106,15 @@ public List getActiveProductIdsByBrandId(Long brandId) {
* 상품 정보를 수정한다.
*
* @param product 상품 수정 정보 (productId 포함)
+ * @return 수정된 상품
* @throws CoreException 상품이 존재하지 않는 경우
*/
@Transactional
- public void update(ModifyProduct product) {
+ public Product update(ModifyProduct product) {
Product entity = productRepository.findById(product.productId())
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
entity.update(product);
+ return entity;
}
/**
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWriter.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWriter.java
new file mode 100644
index 000000000..0eca3d305
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWriter.java
@@ -0,0 +1,89 @@
+package com.loopers.domain.product;
+
+import static com.loopers.domain.product.ProductCacheConstants.*;
+
+import java.util.Objects;
+
+import com.loopers.domain.shared.annotation.DomainService;
+import com.loopers.domain.shared.cache.CacheRepository;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 상품 쓰기를 담당하는 도메인 서비스.
+ *
+ * DB 변경과 캐시 무효화/Write-Through를 함께 처리한다.
+ * UseCase는 이 서비스만 호출하면 되며, 캐시의 존재를 알 필요가 없다.
+ *
+ * @see ProductReader
+ */
+@DomainService
+@RequiredArgsConstructor
+public class ProductWriter {
+
+ private final ProductService productService;
+ private final CacheRepository cacheRepository;
+
+ /**
+ * 상품을 소프트 삭제하고, 삭제에 성공하면 목록 및 상세 캐시를 무효화한다.
+ *
+ * @param productId 상품 ID
+ * @return 실제로 삭제가 수행되었으면 true, 이미 삭제된 상태면 false
+ */
+ public boolean delete(Long productId) {
+ boolean deleted = productService.delete(productId);
+ if (deleted) {
+ cacheRepository.evict(LIST_KEY.pattern());
+ cacheRepository.evict(DETAIL_KEY.of(productId));
+ }
+ return deleted;
+ }
+
+ /**
+ * 상품 정보를 수정하고, 상세 캐시를 overwrite한다.
+ *
+ * 목록 캐시(ID 리스트)는 evict하지 않는다. 목록 캐시에는 ID만 저장되어 있으므로
+ * 상품 데이터 변경이 ID 리스트에 영향을 주지 않는다.
+ *
+ * @param product 상품 수정 정보
+ */
+ public void update(ModifyProduct product) {
+ Product updated = productService.update(product);
+ cacheRepository.put(DETAIL_KEY.of(product.productId()), updated, DETAIL_TTL);
+ }
+
+ /**
+ * 상품의 좋아요 수를 1 증가시키고, 상세 캐시에 Write-Through한다.
+ *
+ * @param productId 상품 ID
+ */
+ public void increaseLikeCount(Long productId) {
+ productService.increaseLikeCount(productId);
+ updateCachedLikeCount(productId, 1);
+ }
+
+ /**
+ * 상품의 좋아요 수를 1 감소시키고, 상세 캐시에 Write-Through한다.
+ *
+ * @param productId 상품 ID
+ */
+ public void decreaseLikeCount(Long productId) {
+ productService.decreaseLikeCount(productId);
+ updateCachedLikeCount(productId, -1);
+ }
+
+ /**
+ * 캐시된 상품의 좋아요 수를 Write-Through로 갱신한다. 캐시 miss이면 아무 작업도 하지 않는다.
+ */
+ private void updateCachedLikeCount(Long productId, int delta) {
+ String key = DETAIL_KEY.of(productId);
+ Product cached = cacheRepository.get(key, PRODUCT_TYPE);
+
+ if (Objects.isNull(cached)) {
+ return;
+ }
+
+ cached.adjustLikeCount(delta);
+ cacheRepository.put(key, cached, DETAIL_TTL);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
index 4710aeb9d..22b8393f1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
@@ -1,6 +1,8 @@
package com.loopers.domain.shared.cache;
import java.time.Duration;
+import java.util.List;
+import java.util.Map;
/**
* 키-값 기반 캐시 저장소 인터페이스.
@@ -38,6 +40,23 @@ public interface CacheRepository {
*/
T get(String key, CacheType type);
+ /**
+ * 여러 키의 값을 한 번에 조회한다.
+ *
+ * @param keys 캐시 키 목록
+ * @param type 역직렬화 대상 타입 토큰
+ * @return 키 순서대로의 값 목록, 캐시 미스 또는 역직렬화 실패 시 해당 위치에 {@code null}
+ */
+ List multiGet(List keys, CacheType type);
+
+ /**
+ * 여러 키-값 쌍을 한 번에 저장하며, 각 키에 동일한 TTL을 적용한다.
+ *
+ * @param entries 캐시 키-값 맵
+ * @param ttl 만료 시간
+ */
+ void multiPut(Map entries, Duration ttl);
+
/**
* 패턴에 매칭되는 캐시 키를 일괄 삭제한다.
*
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
index b9a8f1999..74b87a682 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
@@ -1,8 +1,12 @@
package com.loopers.infrastructure.shared.cache;
import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
+import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
@@ -87,6 +91,42 @@ public T get(String key, CacheType type) {
}
}
+ @Override
+ public List multiGet(List keys, CacheType type) {
+ if (keys.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List jsonList = redisTemplate.opsForValue().multiGet(keys);
+ if (jsonList == null) {
+ return keys.stream().map(k -> (T) null).toList();
+ }
+ JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType());
+ return jsonList.stream()
+ .map(json -> this.deserializeOrNull(json, javaType))
+ .toList();
+ }
+
+ @Override
+ public void multiPut(Map entries, Duration ttl) {
+ if (entries.isEmpty()) {
+ return;
+ }
+ redisTemplate.executePipelined((RedisConnection connection) -> {
+ entries.forEach((key, value) -> {
+ try {
+ String json = objectMapper.writeValueAsString(value);
+ connection.stringCommands().setEx(
+ key.getBytes(), ttl.getSeconds(), json.getBytes()
+ );
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 직렬화 실패, key={}", key, e);
+ }
+ });
+ return null;
+ });
+ log.debug("Cache MULTI_PUT — keys={}", entries.keySet());
+ }
+
@Override
public void evict(String keyPattern) {
Set keys = redisTemplate.keys(keyPattern);
@@ -95,4 +135,16 @@ public void evict(String keyPattern) {
log.debug("Cache EVICT — pattern={}, deletedKeys={}", keyPattern, keys.size());
}
}
+
+ private T deserializeOrNull(String json, JavaType javaType) {
+ if (json == null) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(json, javaType);
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 역직렬화 실패", e);
+ return null;
+ }
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductFixture.java
new file mode 100644
index 000000000..2befc155b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductFixture.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.product;
+
+import org.springframework.test.util.ReflectionTestUtils;
+
+public class ProductFixture {
+
+ public static Product createProduct(Long id) {
+ var product = Product.create(new ProductSpec(1L, "상품" + id, "http://example.com/" + id + ".jpg", 10000L, 50L, null));
+ ReflectionTestUtils.setField(product, "id", id);
+ return product;
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReaderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReaderTest.java
new file mode 100644
index 000000000..9d1ee56fc
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReaderTest.java
@@ -0,0 +1,156 @@
+package com.loopers.domain.product;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.never;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.DisplayName;
+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.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.loopers.domain.shared.cache.CacheRepository;
+import com.loopers.domain.shared.cache.CacheType;
+import com.loopers.support.page.Page;
+import com.loopers.support.page.PageSize;
+
+@ExtendWith(MockitoExtension.class)
+class ProductReaderTest {
+
+ @InjectMocks
+ private ProductReader productReader;
+
+ @Mock
+ private CacheRepository cacheRepository;
+
+ @Mock
+ private ProductService productService;
+
+ @Captor
+ private ArgumentCaptor