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/.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/.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/.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/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 62f8e9d9e..4213c5e66 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -23,6 +23,9 @@ dependencies {
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))
+ // awaitility
+ testImplementation("org.awaitility:awaitility:4.2.2")
+
// archunit
testImplementation("com.tngtech.archunit:archunit-junit5:${project.properties["archunitVersion"]}")
}
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 f1024a3dd..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
@@ -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,10 @@ public void deductStock(Long quantity) {
this.stock.deduct(quantity);
}
+ 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);
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..50acb3993
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheConstants.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.product;
+
+import java.time.Duration;
+import java.util.concurrent.ThreadLocalRandom;
+
+import com.loopers.domain.shared.cache.CacheKey;
+import com.loopers.domain.shared.cache.CacheType;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * 상품 캐시에서 공유하는 키·TTL·타입 상수.
+ *
+ * TTL 메서드는 호출마다 ±10% 범위의 jitter를 적용하여
+ * 동시 만료로 인한 thundering herd를 방지한다.
+ *
+ * @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");
+
+ private static final Duration LIST_BASE_TTL = Duration.ofMinutes(1);
+ private static final Duration DETAIL_BASE_TTL = Duration.ofMinutes(5);
+ private static final double JITTER_RATIO = 0.1;
+
+ static final CacheType PRODUCT_TYPE = new CacheType<>() {};
+
+ static Duration listTtl() {
+ return applyJitter(LIST_BASE_TTL);
+ }
+
+ static Duration detailTtl() {
+ return applyJitter(DETAIL_BASE_TTL);
+ }
+
+ /**
+ * 기준 TTL에 ±{@link #JITTER_RATIO} 범위의 랜덤 오프셋을 더해 반환한다.
+ * 동일 시점에 캐싱된 키들의 만료 시점을 분산시켜 thundering herd를 방지한다.
+ *
+ * @param base 기준 TTL
+ * @return jitter가 적용된 TTL (최소 1초 보장)
+ */
+ private static Duration applyJitter(Duration base) {
+ long baseSeconds = base.getSeconds();
+ long jitterBound = Math.max(1, (long) (baseSeconds * JITTER_RATIO));
+ long offset = ThreadLocalRandom.current().nextLong(-jitterBound, jitterBound + 1);
+ return Duration.ofSeconds(Math.max(1, baseSeconds + offset));
+ }
+}
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..e6ed3f609
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java
@@ -0,0 +1,234 @@
+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 java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+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 static final long LOCK_TIMEOUT_SECONDS = 3;
+
+ private final CacheRepository cacheRepository;
+ private final ProductService productService;
+
+ private final ConcurrentMap locks = new ConcurrentHashMap<>();
+
+ /**
+ * 활성 상품 목록을 페이지 단위로 조회한다.
+ *
+ * @param brandId 브랜드 ID (null이면 전체 브랜드)
+ * @param sortType 정렬 기준
+ * @param pageSize 페이지 정보
+ * @return 활성 상품 목록 페이지
+ */
+ public Page readActiveProducts(Long brandId, ProductSortType sortType, PageSize pageSize) {
+ ProductSortType resolvedSortType = sortType != null ? sortType : ProductSortType.DEFAULT;
+
+ if (!isCacheablePage(pageSize.page())) {
+ return fetchAndCacheProducts(brandId, resolvedSortType, pageSize);
+ }
+
+ String listKey = buildListKey(brandId, resolvedSortType, pageSize);
+ ProductIdPage idPage = cacheRepository.get(listKey, ID_PAGE_TYPE);
+
+ if (Objects.nonNull(idPage)) {
+ return resolveProductsFromIdPage(idPage);
+ }
+
+ ReentrantLock lock = locks.computeIfAbsent(listKey, k -> new ReentrantLock());
+ if (tryLockWithTimeout(lock)) {
+ try {
+ ProductIdPage rechecked = cacheRepository.get(listKey, ID_PAGE_TYPE);
+ if (Objects.nonNull(rechecked)) {
+ return resolveProductsFromIdPage(rechecked);
+ }
+ return fetchAndCacheProducts(brandId, resolvedSortType, pageSize);
+ } finally {
+ releaseLock(listKey, lock);
+ }
+ }
+
+ return fetchAndCacheProducts(brandId, resolvedSortType, 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;
+ }
+
+ ReentrantLock lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
+ if (tryLockWithTimeout(lock)) {
+ try {
+ Product rechecked = cacheRepository.get(key, PRODUCT_TYPE);
+ if (Objects.nonNull(rechecked)) {
+ return rechecked;
+ }
+ Product product = productService.getActiveProduct(productId);
+ cacheRepository.put(key, product, detailTtl());
+ return product;
+ } finally {
+ releaseLock(key, lock);
+ }
+ }
+
+ return productService.getActiveProduct(productId);
+ }
+
+ private String buildListKey(Long brandId, ProductSortType sortType, PageSize pageSize) {
+ String brandSegment = Objects.nonNull(brandId) ? String.valueOf(brandId) : ALL_BRAND;
+ return LIST_KEY.of(brandSegment, sortType.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에서 조회하고 상세 캐시(개별 상품)와 목록 캐시(ID 리스트)에 저장한다.
+ */
+ 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, listTtl());
+ }
+
+ 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, ProductCacheConstants::detailTtl);
+ }
+
+ private boolean isCacheablePage(int page) {
+ return page <= MAX_CACHEABLE_PAGE;
+ }
+
+ /**
+ * 현재 보유 중인 Lock 수를 반환한다. 테스트 전용.
+ */
+ int lockCount() {
+ return locks.size();
+ }
+
+ /**
+ * Lock을 해제하고, 대기 중인 스레드가 없으면 map에서 제거하여 메모리 누수를 방지한다.
+ */
+ private void releaseLock(String key, ReentrantLock lock) {
+ lock.unlock();
+ if (!lock.hasQueuedThreads()) {
+ locks.remove(key, lock);
+ }
+ }
+
+ private boolean tryLockWithTimeout(ReentrantLock lock) {
+ try {
+ return lock.tryLock(LOCK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+
+ /**
+ * 목록 캐시에 저장되는 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/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
+}
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..078af5cf4
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductWriter.java
@@ -0,0 +1,80 @@
+package com.loopers.domain.product;
+
+import static com.loopers.domain.product.ProductCacheConstants.*;
+
+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, detailTtl());
+ }
+
+ /**
+ * 상품의 좋아요 수를 1 증가시키고, 상세 캐시에 Write-Through한다.
+ *
+ * @param productId 상품 ID
+ */
+ public void increaseLikeCount(Long productId) {
+ productService.increaseLikeCount(productId);
+ refreshCache(productId);
+ }
+
+ /**
+ * 상품의 좋아요 수를 1 감소시키고, 상세 캐시에 Write-Through한다.
+ *
+ * @param productId 상품 ID
+ */
+ public void decreaseLikeCount(Long productId) {
+ productService.decreaseLikeCount(productId);
+ refreshCache(productId);
+ }
+
+ /**
+ * DB에서 최신 상품을 조회하여 상세 캐시를 갱신한다.
+ */
+ private void refreshCache(Long productId) {
+ Product product = productService.getActiveProduct(productId);
+ cacheRepository.put(DETAIL_KEY.of(productId), product, detailTtl());
+ }
+}
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..9447518d2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/shared/cache/CacheRepository.java
@@ -0,0 +1,68 @@
+package com.loopers.domain.shared.cache;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * 키-값 기반 캐시 저장소 인터페이스.
+ *
+ * 특정 캐시 구현(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 keys 캐시 키 목록
+ * @param type 역직렬화 대상 타입 토큰
+ * @return 키 순서대로의 값 목록, 캐시 미스 또는 역직렬화 실패 시 해당 위치에 {@code null}
+ */
+ List multiGet(List keys, CacheType type);
+
+ /**
+ * 여러 키-값 쌍을 한 번에 저장하며, 각 키마다 개별 TTL을 적용한다.
+ * TTL은 키마다 {@code ttlSupplier}를 호출하여 결정한다.
+ *
+ * @param entries 캐시 키-값 맵
+ * @param ttlSupplier 키마다 호출되는 TTL 공급자
+ */
+ void multiPut(Map entries, Supplier ttlSupplier);
+
+ /**
+ * 패턴에 매칭되는 캐시 키를 일괄 삭제한다.
+ *
+ * @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..b0e7876fe
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/shared/cache/RedisCacheRepository.java
@@ -0,0 +1,178 @@
+package com.loopers.infrastructure.shared.cache;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ScanOptions;
+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 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, Supplier ttlSupplier) {
+ if (entries.isEmpty()) {
+ return;
+ }
+ redisTemplate.executePipelined((RedisConnection connection) -> {
+ entries.forEach((key, value) -> {
+ try {
+ String json = objectMapper.writeValueAsString(value);
+ connection.stringCommands().setEx(
+ key.getBytes(StandardCharsets.UTF_8), ttlSupplier.get().getSeconds(), json.getBytes(StandardCharsets.UTF_8)
+ );
+ } catch (JsonProcessingException e) {
+ log.warn("캐시 직렬화 실패, key={}", key, e);
+ }
+ });
+ return null;
+ });
+ log.debug("Cache MULTI_PUT — keys={}", entries.keySet());
+ }
+
+ @Override
+ public void evict(String keyPattern) {
+ if (!keyPattern.contains("*")) {
+ redisTemplate.delete(keyPattern);
+ log.debug("Cache EVICT — key={}", keyPattern);
+ return;
+ }
+
+ int batchSize = 100;
+ ScanOptions options = ScanOptions.scanOptions().match(keyPattern).count(batchSize).build();
+ long totalDeleted = 0;
+
+ try (Cursor cursor = redisTemplate.scan(options)) {
+ List batch = new ArrayList<>(batchSize);
+ while (cursor.hasNext()) {
+ batch.add(cursor.next());
+ if (batch.size() >= batchSize) {
+ redisTemplate.delete(batch);
+ totalDeleted += batch.size();
+ batch.clear();
+ }
+ }
+ if (!batch.isEmpty()) {
+ redisTemplate.delete(batch);
+ totalDeleted += batch.size();
+ }
+ }
+
+ if (totalDeleted > 0) {
+ log.debug("Cache EVICT — pattern={}, deletedKeys={}", keyPattern, totalDeleted);
+ }
+ }
+
+ 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..26d85360b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductReaderTest.java
@@ -0,0 +1,309 @@
+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 static org.mockito.Mockito.times;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+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 {
+
+ private ProductReader productReader;
+
+ @Mock
+ private CacheRepository cacheRepository;
+
+ @Mock
+ private ProductService productService;
+
+ @Captor
+ private ArgumentCaptor