From 71323c42f9614ec60517a37b307d019520764d45 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 08:47:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=BF=BC=EB=A6=AC=20=EC=84=B1=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=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 Sonnet 4.6 --- .docs/product-index-analysis.md | 376 ++++++++++++++++++ .../product/ProductCacheRepository.java | 19 + .../product/ProductPageResult.java | 27 ++ .../java/com/loopers/config/CacheConfig.java | 9 + .../com/loopers/config/CacheProperties.java | 27 ++ .../com/loopers/domain/product/Product.java | 11 +- .../product/ProductCacheRepositoryImpl.java | 93 +++++ .../product/ProductJpaRepository.java | 3 +- modules/jpa/src/main/resources/jpa.yml | 5 +- 9 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 .docs/product-index-analysis.md create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java diff --git a/.docs/product-index-analysis.md b/.docs/product-index-analysis.md new file mode 100644 index 000000000..0bee9632e --- /dev/null +++ b/.docs/product-index-analysis.md @@ -0,0 +1,376 @@ +# 상품 목록 조회 인덱스 성능 분석 보고서 + +## 1. 개요 + +상품 목록 조회 API(`GET /api/v1/products`)의 쿼리 실행 계획을 분석하고, +인덱스 미적용 vs 개별 인덱스 단위 적용 상황을 비교하여 최적의 인덱스 전략을 도출한다. + +--- + +## 2. 테스트 환경 + +| 항목 | 내용 | +|------|------| +| 테이블 | `product` | +| 데이터 건수 | 100,000건 (활성 ~94,861건 / 삭제 ~5,139건) | +| 기존 인덱스 | PK(`id`) 1개만 존재 | +| MySQL 버전 | InnoDB Engine | +| 브랜드 필터 테스트 대상 | brand_id = 1 (활성 상품 14,254건) | + +### 테이블 스키마 (인덱스 적용 전) + +```sql +CREATE TABLE `product` ( + `like_count` int NOT NULL, + `price` int NOT NULL, + `stock` int NOT NULL, + `brand_id` bigint NOT NULL, + `created_at` datetime(6) NOT NULL, + `deleted_at` datetime(6) DEFAULT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, + `updated_at` datetime(6) NOT NULL, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB; +``` + +--- + +## 3. 분석 대상 유즈케이스 + +상품 목록 API(`GET /api/v1/products`)는 다음 파라미터 조합으로 쿼리가 발생한다. + +### 정렬 기준 (`ProductSortType`) + +| sort 파라미터 | 정렬 컬럼 | 방향 | +|-------------|---------|------| +| `latest` (기본) | `created_at` | **DESC** | +| `price_asc` | `price` | **ASC** | +| `likes_desc` | `like_count` | **DESC** | + +### 쿼리 목록 + +| # | brandId 필터 | 정렬 기준 | 실행되는 SQL 요약 | +|---|-------------|----------|-----------------| +| Q1 | 없음 | latest | `WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 20` | +| Q2 | 없음 | price_asc | `WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20` | +| Q3 | 없음 | likes_desc | `WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20` | +| Q4 | 없음 | (COUNT) | `SELECT COUNT(*) WHERE deleted_at IS NULL` | +| Q5 | 있음 | latest | `WHERE brand_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20` | +| Q6 | 있음 | price_asc | `WHERE brand_id = ? AND deleted_at IS NULL ORDER BY price ASC LIMIT 20` | +| Q7 | 있음 | likes_desc | `WHERE brand_id = ? AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20` | +| Q8 | 있음 | (COUNT) | `SELECT COUNT(*) WHERE brand_id = ? AND deleted_at IS NULL` | + +> Spring Data JPA의 Page 조회는 데이터 쿼리 + COUNT 쿼리를 각 1회씩 실행한다. +> API 호출 한 번에 Q1~Q3 중 하나 + Q4가 함께, Q5~Q7 중 하나 + Q8이 함께 발생한다. + +--- + +## 4. 테스트 방법론 + +### 인덱스 개별 단위 테스트 + +각 인덱스의 독립적 기여도를 정확히 측정하기 위해 다음 절차를 반복했다. + +``` +① ADD INDEX (인덱스 1개만 존재) +② 해당 인덱스가 영향을 주는 쿼리를 2회 실행 (버퍼풀 워밍업) +③ 실제 측정 (1회) +④ EXPLAIN 실행 +⑤ DROP INDEX +→ 다음 인덱스로 반복 +``` + +### 버퍼풀 워밍업의 필요성 + +첫 번째 실행은 디스크 I/O가 발생하는 **콜드 상태**이고, 두 번째부터는 데이터가 메모리에 적재된 **워밍업 상태**다. +워밍업 없이 측정하면 실제 캐시 히트율이 높은 운영 환경과 다른 수치가 나온다. + +### 6개 인덱스 동시 적용의 문제점 + +여러 인덱스가 공존하면 **optimizer가 여러 선택지 중 하나를 고르는 과정**에서 예상치 못한 인덱스를 선택할 수 있다. +본 테스트에서 Q4(COUNT)가 이 문제를 실증했다 (7절 참고). + +--- + +## 5. 인덱스 미적용 상태 (베이스라인) + +> 버퍼풀 워밍업 후 측정. 8개 쿼리 전부 Full Table Scan 발생. + +| # | type | rows | Extra | 실행 시간 | +|---|------|------|-------|---------| +| Q1 | ALL | 99,596 | Using where; **Using filesort** | 38.43ms | +| Q2 | ALL | 99,596 | Using where; **Using filesort** | 31.35ms | +| Q3 | ALL | 99,596 | Using where; **Using filesort** | 32.12ms | +| Q4 | ALL | 99,596 | Using where | 12.30ms | +| Q5 | ALL | 99,596 | Using where; **Using filesort** | 29.41ms | +| Q6 | ALL | 99,596 | Using where; **Using filesort** | 23.23ms | +| Q7 | ALL | 99,596 | Using where; **Using filesort** | 27.13ms | +| Q8 | ALL | 99,596 | Using where | 19.47ms | + +### 문제점 분석 + +**① Full Table Scan (type: ALL)** +인덱스를 전혀 활용하지 못해 100,000건 전수 스캔 발생. +데이터가 증가할수록 선형으로 성능이 저하된다. + +**② Using filesort** +인덱스에서 정렬 순서를 얻지 못해 별도 메모리/디스크 정렬 작업 필요. +Q1~Q3, Q5~Q7처럼 ORDER BY가 있는 쿼리에서 매 요청마다 발생한다. + +**③ Q5~Q8: filtered 1%** +`brand_id = ?` 조건이 있는 쿼리에서 `filtered = 1`로 측정됨. +optimizer가 전체를 스캔한 뒤 1%만 조건에 일치한다고 추정 → 불필요한 99% 행을 읽고 버리는 낭비 발생. + +--- + +## 6. 추가한 인덱스 목록 + +### 인덱스 선언 시 정렬 방향 정책 + +인덱스의 정렬 컬럼 방향을 **실제 쿼리의 ORDER BY 방향과 일치**시켜야 한다. +방향이 다를 경우 InnoDB가 B-Tree를 역방향으로 읽는 **Backward index scan**이 발생하고, +페이지 탐색 방향이 반대가 되어 ICP(Index Condition Pushdown) 활성화가 제한될 수 있다. + +| sort 파라미터 | ORDER BY | 인덱스 정렬 방향 | +|-------------|----------|--------------| +| `latest` | `created_at DESC` | `created_at DESC` | +| `price_asc` | `price ASC` | `price ASC` (기본값과 동일) | +| `likes_desc` | `like_count DESC` | `like_count DESC` | + +### 인덱스 DDL + +```sql +-- 전체 상품 조회 (brandId 없음) 3종 +ALTER TABLE product ADD INDEX idx_deleted_created (deleted_at, created_at DESC); +ALTER TABLE product ADD INDEX idx_deleted_price (deleted_at, price); +ALTER TABLE product ADD INDEX idx_deleted_likes (deleted_at, like_count DESC); + +-- 브랜드별 상품 조회 (brandId 있음) 3종 +ALTER TABLE product ADD INDEX idx_brand_deleted_created (brand_id, deleted_at, created_at DESC); +ALTER TABLE product ADD INDEX idx_brand_deleted_price (brand_id, deleted_at, price); +ALTER TABLE product ADD INDEX idx_brand_deleted_likes (brand_id, deleted_at, like_count DESC); +``` + +### 컬럼 순서 설계 원칙 + +``` +등가 조건(=) → IS NULL 조건 → 정렬 컬럼(방향 일치) +``` + +| 인덱스명 | 컬럼 구성 | 설계 의도 | +|---------|---------|---------| +| `idx_deleted_created` | `(deleted_at, created_at DESC)` | soft delete 필터 + 최신순 정렬 | +| `idx_deleted_price` | `(deleted_at, price)` | soft delete 필터 + 가격 오름차순 정렬 | +| `idx_deleted_likes` | `(deleted_at, like_count DESC)` | soft delete 필터 + 좋아요 내림차순 정렬 | +| `idx_brand_deleted_created` | `(brand_id, deleted_at, created_at DESC)` | 브랜드 등가 + soft delete + 최신순 정렬 | +| `idx_brand_deleted_price` | `(brand_id, deleted_at, price)` | 브랜드 등가 + soft delete + 가격 오름차순 정렬 | +| `idx_brand_deleted_likes` | `(brand_id, deleted_at, like_count DESC)` | 브랜드 등가 + soft delete + 좋아요 내림차순 정렬 | + +--- + +## 7. 인덱스 개별 적용 — EXPLAIN 및 실행 시간 + +> 각 인덱스를 단독으로 추가한 상태에서 버퍼풀 워밍업 후 측정. + +### 7-1. idx_deleted_created (deleted_at, created_at DESC) + +해당 쿼리: **Q1** (전체/최신), **Q4** (전체/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q1 | ref | idx_deleted_created | 49,798 | **Using index condition** | 2.48ms | +| Q4 | ref | idx_deleted_created | 49,798 | Using where; **Using index** | 19.69ms | + +### 7-2. idx_deleted_price (deleted_at, price) + +해당 쿼리: **Q2** (전체/가격), **Q4** (전체/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q2 | ref | idx_deleted_price | 49,798 | **Using index condition** | 1.89ms | +| Q4 | ref | idx_deleted_price | 49,798 | Using where; **Using index** | 17.47ms | + +### 7-3. idx_deleted_likes (deleted_at, like_count DESC) + +해당 쿼리: **Q3** (전체/좋아요), **Q4** (전체/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q3 | ref | idx_deleted_likes | 49,798 | **Using index condition** | 1.28ms | +| Q4 | ref | idx_deleted_likes | 49,798 | Using where; **Using index** | 18.83ms | + +### 7-4. idx_brand_deleted_created (brand_id, deleted_at, created_at DESC) + +해당 쿼리: **Q5** (브랜드/최신), **Q8** (브랜드/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q5 | ref | idx_brand_deleted_created | 28,704 | **Using index condition** | 1.64ms | +| Q8 | ref | idx_brand_deleted_created | 28,704 | Using where; **Using index** | 4.91ms | + +### 7-5. idx_brand_deleted_price (brand_id, deleted_at, price) + +해당 쿼리: **Q6** (브랜드/가격), **Q8** (브랜드/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q6 | ref | idx_brand_deleted_price | 27,648 | **Using index condition** | 1.73ms | +| Q8 | ref | idx_brand_deleted_price | 27,648 | Using where; **Using index** | 4.18ms | + +### 7-6. idx_brand_deleted_likes (brand_id, deleted_at, like_count DESC) + +해당 쿼리: **Q7** (브랜드/좋아요), **Q8** (브랜드/COUNT) + +| # | type | key | rows | Extra | 실행 시간 | +|---|------|-----|------|-------|---------| +| Q7 | ref | idx_brand_deleted_likes | 27,648 | **Using index condition** | 2.62ms | +| Q8 | ref | idx_brand_deleted_likes | 27,648 | Using where; **Using index** | 5.59ms | + +--- + +## 8. 전·후 핵심 수치 비교 (개별 인덱스 기준) + +| 쿼리 | 베이스라인 | 인덱스 적용 후 | 개선율 | Extra 변화 | +|------|----------|-------------|------|-----------| +| Q1 (전체/최신) | 38.43ms | **2.48ms** | 약 15배 ↑ | Using filesort → **Using index condition** | +| Q2 (전체/가격) | 31.35ms | **1.89ms** | 약 17배 ↑ | Using filesort → **Using index condition** | +| Q3 (전체/좋아요) | 32.12ms | **1.28ms** | 약 25배 ↑ | Using filesort → **Using index condition** | +| Q4 (전체/COUNT) | 12.30ms | **17~19ms** | 약 1.4배 ↓ | Using where → **Using index** (커버링)* | +| Q5 (브랜드/최신) | 29.41ms | **1.64ms** | 약 18배 ↑ | Using filesort → **Using index condition** | +| Q6 (브랜드/가격) | 23.23ms | **1.73ms** | 약 13배 ↑ | Using filesort → **Using index condition** | +| Q7 (브랜드/좋아요) | 27.13ms | **2.62ms** | 약 10배 ↑ | Using filesort → **Using index condition** | +| Q8 (브랜드/COUNT) | 19.47ms | **4~5ms** | 약 4~5배 ↑ | Using where → **Using index** (커버링) | + +> \* Q4는 개별 인덱스 기준으로 커버링 인덱스 동작. 오히려 베이스라인보다 소폭 느린 이유는 9절 참고. + +--- + +## 9. 개선 포인트 분석 + +**① Full Table Scan → ref 스캔으로 전환** +모든 쿼리에서 `type: ALL` → `type: ref`로 개선. +`deleted_at IS NULL`이 등가 조건(=)으로 처리되어, 인덱스의 NULL 구간만 스캔한다. + +**② Using filesort 완전 제거 + ICP 활성화 (Q1~Q3, Q5~Q7)** +정렬 컬럼을 쿼리 방향과 일치시킨 덕분에 Backward scan 없이 Forward scan으로 동작하며, +ICP(Index Condition Pushdown)가 활성화되어 스토리지 엔진 레벨에서 WHERE 필터링을 처리한다. +불필요한 행을 버퍼풀에 올리지 않아 메모리 I/O도 절감된다. + +**③ COUNT(*) 쿼리의 커버링 인덱스 동작 (Q4, Q8)** +`deleted_at`을 선두 컬럼으로 가진 인덱스는 COUNT(*) 시 `Using index` 커버링 인덱스로 동작한다. +인덱스 페이지만 읽고 실제 행 데이터에는 전혀 접근하지 않는다. + +Q8은 베이스라인 대비 4~5배 빠르지만, Q4는 오히려 베이스라인(12.30ms)보다 소폭 느리다(17~19ms). +이는 버퍼풀에 데이터가 모두 올라온 상태에서 Full Table Scan의 **순차 I/O**가 +인덱스 B-Tree 탐색 오버헤드보다 효율적이기 때문이다. +데이터 건수가 증가하면 커버링 인덱스가 압도적으로 유리해진다. + +**④ 6개 인덱스 동시 환경에서 Q4 Optimizer의 오선택 (주의)** + +동일한 Q4를 6개 인덱스가 모두 존재하는 환경에서 실행하면, optimizer가 +`idx_brand_deleted_price`의 **skip scan**을 선택해 **56.72ms**로 오히려 느려진다. +skip scan은 brand_id 고유값마다 내부 범위를 반복 탐색해 **Random I/O**가 많아지기 때문이다. + +| 테스트 환경 | 선택된 인덱스 | Extra | 실행 시간 | +|-----------|------------|-------|---------| +| 개별 인덱스 (idx_deleted_created만 존재) | idx_deleted_created | Using index (커버링) | 19.69ms | +| 6개 인덱스 동시 존재 | idx_brand_deleted_price | **Using index for skip scan** | **56.72ms** | + +> 이것이 "인덱스를 많이 달수록 좋다"는 생각이 위험한 이유다. +> Optimizer는 여러 인덱스가 있을 때 오히려 잘못된 선택을 할 수 있으며, +> 개별 단위 테스트가 필요한 핵심 이유가 여기에 있다. + +--- + +## 10. EXPLAIN 주요 용어 설명 + +| 용어 | 의미 | +|------|------| +| `type: ALL` | Full Table Scan — 인덱스 미사용, 전 행 스캔 | +| `type: index` | 인덱스 Full Scan — 테이블 대신 인덱스 전체를 순회 (LIMIT와 함께 Early stop 가능) | +| `type: ref` | 인덱스 등가 조건 스캔 — 특정 값과 일치하는 행만 탐색 | +| `type: range` | 인덱스 범위 스캔 — IS NULL 등 범위 조건 처리 | +| `Using filesort` | 별도 정렬 작업 발생 — 메모리 또는 디스크 정렬 | +| `Using where` | 스토리지 엔진에서 가져온 행을 서버 레이어에서 추가 필터링 | +| `Using index condition` | ICP 활성화 — 스토리지 엔진 레벨에서 WHERE 조건 평가, 불필요한 행 접근 감소 | +| `Using index` | 커버링 인덱스 — 실제 행 접근 없이 인덱스만으로 결과 완성 | +| `Using index for skip scan` | leading 컬럼을 건너뛰고 내부 컬럼 범위 반복 스캔 — Random I/O 주의 | +| `Backward index scan` | 인덱스 방향과 정렬 방향 불일치 — 역방향 탐색 (Forward보다 비효율) | + +--- + +## 11. `deleted_at IS NULL` 인덱스의 한계 + +`deleted_at IS NULL` 조건의 **selectivity(선택도)가 낮다**는 점은 주의해야 한다. + +- 본 테스트 환경에서는 삭제된 상품이 ~5,139건으로 전체의 약 5% — `deleted_at IS NULL`이 약 95%를 차지 +- 인덱스 적용 후에도 Q1~Q3에서 여전히 ~49,798건을 스캔하는 이유가 이것 +- MySQL optimizer는 selectivity가 너무 낮으면 인덱스를 무시하고 Full Scan을 선택하는 경우도 있음 + +> 만약 삭제 비율이 낮아 `deleted_at IS NULL`이 전체의 80% 이상이라면, +> `(deleted_at, ...)` 인덱스보다 **고선택도 컬럼을 앞에 두는 것**을 검토할 수 있다. +> 예: `(created_at DESC)` 단독 인덱스 — LIMIT와 함께 Early stop이 가능해 실제 스캔 행 수를 획기적으로 줄일 수 있다. +> 단, COUNT(*) 쿼리에서 deleted_at 필터를 인덱스로 처리할 수 없어 커버링 인덱스 효과가 사라진다. + +--- + +## 12. 쓰기 비용 (Write Amplification) 고려 + +인덱스가 늘어날수록 INSERT / UPDATE / DELETE 시 B-Tree 갱신 비용이 증가한다. + +### 작업별 인덱스 갱신 범위 + +| 작업 | 영향받는 인덱스 수 | +|------|----------------| +| `INSERT product` | 6개 모두 갱신 | +| `UPDATE price` | idx_deleted_price, idx_brand_deleted_price (2개) | +| `UPDATE like_count` | idx_deleted_likes, idx_brand_deleted_likes (2개) — **좋아요 클릭마다 발생** | +| soft delete (`UPDATE deleted_at`) | 6개 모두 갱신 | + +> **특히 주의**: `like_count`는 좋아요 생성/취소마다 UPDATE되므로 +> `idx_deleted_likes`, `idx_brand_deleted_likes` 두 인덱스는 쓰기 핫스팟이 될 수 있다. + +--- + +## 13. 결론 및 권장 인덱스 전략 + +### 권장 안 (균형 전략) + +조회와 쓰기 비용을 고려한 최소한의 필수 인덱스: + +```sql +-- 브랜드 필터 + 정렬 커버 (선택도 높고 활용 범위 넓음) +ALTER TABLE product ADD INDEX idx_brand_deleted_created (brand_id, deleted_at, created_at DESC); +ALTER TABLE product ADD INDEX idx_brand_deleted_price (brand_id, deleted_at, price); +ALTER TABLE product ADD INDEX idx_brand_deleted_likes (brand_id, deleted_at, like_count DESC); + +-- 전체 조회 (brandId 없음) — selectivity 낮아 효과 제한적이나 filesort 제거 효과 있음 +ALTER TABLE product ADD INDEX idx_deleted_created (deleted_at, created_at DESC); +``` + +### 인덱스별 트레이드오프 + +| 인덱스 | 조회 이득 | 쓰기 비용 | 권장 여부 | +|-------|---------|---------|---------| +| `idx_brand_deleted_created` | 브랜드 필터 최신순 (가장 일반적인 케이스) | 낮음 | ✅ 필수 | +| `idx_brand_deleted_price` | 브랜드 필터 가격순 + Q8 커버링 | 낮음 | ✅ 필수 | +| `idx_brand_deleted_likes` | 브랜드 필터 좋아요순 | like_count UPDATE 빈번 시 부하 | ⚠️ 트래픽 확인 후 결정 | +| `idx_deleted_created` | 전체 최신순 (selectivity 낮아 효과 제한) | 낮음 | ✅ 권장 | +| `idx_deleted_price` | 전체 가격순 | 낮음 | ⚠️ 사용 빈도에 따라 결정 | +| `idx_deleted_likes` | 전체 좋아요순 | like_count UPDATE마다 갱신 | ⚠️ 트래픽 확인 후 결정 | + +### 핵심 메시지 + +> "인덱스를 많이 달수록 읽기 성능이 좋아질 것이라는 기대는 항상 맞지 않는다. +> 본 테스트에서 Q4(COUNT)는 6개 인덱스 동시 환경에서 skip scan(56.72ms)으로 오히려 악화됐지만, +> 개별 인덱스 환경에서는 커버링 인덱스(17~19ms)로 동작했다. +> **각 인덱스를 단독으로 추가하고 EXPLAIN으로 실행 계획을 검증한 뒤 채택하는 것이 필수다.** +> `like_count`처럼 자주 갱신되는 컬럼의 인덱스는 쓰기 핫스팟이 될 수 있으므로, +> 실제 트래픽에서 해당 정렬 옵션의 사용 빈도를 모니터링한 뒤 결정하는 것이 바람직하다." + +--- + +*분석 기준일: 2026-03-12* +*데이터 건수: product 테이블 100,000건* +*테스트 방식: 인덱스를 1개씩 개별 추가/DROP, 버퍼풀 워밍업(2회) 후 측정* diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java new file mode 100644 index 000000000..e3ba5f7dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java @@ -0,0 +1,19 @@ +package com.loopers.application.product; + +import java.util.Optional; + +/** + * 상품 목록 캐시 포트 (Secondary Port / Driven Port). + * application 레이어는 이 인터페이스에만 의존 → Redis 등 구현 기술이 바뀌어도 Facade 코드 변경 없음. + */ +public interface ProductCacheRepository { + + // 캐시 조회 - 미스 시 Optional.empty() 반환 (예외 미전파) + Optional getList(String cacheKey); + + // 캐시 저장 (Redis 장애 시에도 예외 미전파) + void saveList(String cacheKey, ProductPageResult result); + + // 상품 등록/수정/삭제 시 목록 캐시 전체 무효화 + void evictAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java new file mode 100644 index 000000000..1f723d96b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * 상품 목록 조회 결과를 캐시에 저장하기 위한 직렬화 가능 record. + * Page는 PageImpl에 @JsonCreator가 없어 Jackson 역직렬화 불가 → 직접 필드를 선언하여 해결 + */ +public record ProductPageResult( + List products, + int page, + int size, + long totalElements, + int totalPages +) { + public static ProductPageResult from(Page page) { + return new ProductPageResult( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java new file mode 100644 index 000000000..c9e3f630b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java @@ -0,0 +1,9 @@ +package com.loopers.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(CacheProperties.class) +public class CacheConfig { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java new file mode 100644 index 000000000..636f4c277 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java @@ -0,0 +1,27 @@ +package com.loopers.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.HashMap; +import java.util.Map; + +/** + * cache.* 설정을 타입 안전하게 바인딩. + * default-ttl-seconds: 전역 기본 TTL + * ttl-overrides: 캐시 이름별 TTL 재정의 (예: product:list → 180초) + */ +@ConfigurationProperties(prefix = "cache") +public record CacheProperties( + long defaultTtlSeconds, + Map ttlOverrides +) { + public CacheProperties { + if (ttlOverrides == null) { + ttlOverrides = new HashMap<>(); + } + } + + public long getTtlSeconds(String cacheName) { + return ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds); + } +} 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 fe090c059..8ba6fb216 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 @@ -7,13 +7,22 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "product") +@Table( + name = "product", + indexes = { + @Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"), + @Index(name = "idx_brand_deleted_price", columnList = "brand_id, deleted_at, price"), + @Index(name = "idx_brand_deleted_likes", columnList = "brand_id, deleted_at, like_count DESC"), + @Index(name = "idx_deleted_created", columnList = "deleted_at, created_at DESC") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Product extends BaseEntity { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java new file mode 100644 index 000000000..793484b81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java @@ -0,0 +1,93 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductCacheRepository; +import com.loopers.application.product.ProductPageResult; +import com.loopers.config.CacheProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +/** + * RedisTemplate을 직접 사용하는 캐시 구현체. + * - 읽기: defaultRedisTemplate (REPLICA_PREFERRED) → Replica 분산 읽기 + * - 쓰기/삭제: masterRedisTemplate (MASTER) → 복제 지연(lag) 없이 즉시 반영 + * - 모든 예외를 내부에서 흡수 → Redis 장애 시 서비스 정상 동작 보장 (캐시 미스로 처리) + */ +@Slf4j +@Repository +public class ProductCacheRepositoryImpl implements ProductCacheRepository { + + private static final String CACHE_KEY_PATTERN = "loopers:product:list:*"; + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + private final CacheProperties cacheProperties; + + public ProductCacheRepositoryImpl( + RedisTemplate defaultRedisTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate masterRedisTemplate, + ObjectMapper objectMapper, + CacheProperties cacheProperties + ) { + this.readTemplate = defaultRedisTemplate; + this.writeTemplate = masterRedisTemplate; + this.objectMapper = objectMapper; + this.cacheProperties = cacheProperties; + } + + @Override + public Optional getList(String cacheKey) { + try { + String json = readTemplate.opsForValue().get(cacheKey); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, ProductPageResult.class)); + } catch (Exception e) { + log.warn("상품 목록 캐시 조회 실패 (key={}): {}", cacheKey, e.getMessage()); + return Optional.empty(); + } + } + + @Override + public void saveList(String cacheKey, ProductPageResult result) { + try { + String json = objectMapper.writeValueAsString(result); + long ttlSeconds = cacheProperties.getTtlSeconds("product:list"); + writeTemplate.opsForValue().set(cacheKey, json, Duration.ofSeconds(ttlSeconds)); + } catch (Exception e) { + log.warn("상품 목록 캐시 저장 실패 (key={}): {}", cacheKey, e.getMessage()); + } + } + + @Override + public void evictAll() { + try { + // KEYS는 O(N)으로 Redis를 블로킹하므로, SCAN으로 안전하게 순회 + writeTemplate.execute((RedisCallback) connection -> { + ScanOptions options = ScanOptions.scanOptions() + .match(CACHE_KEY_PATTERN) + .count(100) + .build(); + try (var cursor = connection.scan(options)) { + while (cursor.hasNext()) { + connection.del(cursor.next()); + } + } catch (Exception e) { + log.warn("캐시 무효화 scan 오류: {}", e.getMessage()); + } + return null; + }); + } catch (Exception e) { + log.warn("상품 목록 캐시 무효화 실패: {}", e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 213feef76..82521fb57 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -34,8 +34,9 @@ public interface ProductJpaRepository extends JpaRepository { void softDeleteAllByBrandId(@Param("brandId") Long brandId, @Param("deletedAt") ZonedDateTime deletedAt); // 비관적 락 - 재고 차감 전 행 잠금 (동시 주문 시 Lost Update 방지) + // ORDER BY p.id ASC: DB가 PK 오름차순으로 스캔하며 락 획득 -> RDBMS 무관하게 데드락 방지 @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL") + @Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL ORDER BY p.id ASC") List findAllByIdsForUpdate(@Param("ids") List ids); // 원자적 좋아요 수 증가 (read-modify-write 대신 DB 레벨 UPDATE) diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 37f4fb1b0..6cc66bf05 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -36,8 +36,11 @@ spring.config.activate.on-profile: local spring: jpa: show-sql: true + properties: + hibernate: + generate_statistics: true hibernate: - ddl-auto: create + ddl-auto: validate datasource: mysql-jpa: From a791b25e03cf8fa4dbe5ce27d2f9290a6ebf1621 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 08:49:03 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Cache-Aside=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20AFTER=5FCOMMIT=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../product/ProductAdminFacade.java | 7 ++- .../product/ProductCacheEventListener.java | 24 +++++++++++ .../product/ProductCacheEvictEvent.java | 9 ++++ .../application/product/ProductFacade.java | 43 +++++++++++++++++-- .../com/loopers/config/CacheProperties.java | 20 ++++++++- .../api/product/ProductV1Controller.java | 4 +- .../interfaces/api/product/ProductV1Dto.java | 14 +++--- .../src/main/resources/application.yml | 7 +++ 8 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index 01838f03b..179fc9103 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -6,6 +6,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -21,6 +22,7 @@ public class ProductAdminFacade { private final ProductService productService; private final BrandService brandService; private final LikeService likeService; + private final ApplicationEventPublisher eventPublisher; // 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05) @Transactional @@ -28,6 +30,7 @@ public ProductInfo register(ProductRegisterCommand command) { Brand brand = brandService.findById(command.brandId()); // 브랜드 미존재 시 NOT_FOUND 예외 Product product = productService.register( command.brandId(), command.name(), command.price(), command.stock()); + eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약 return ProductInfo.from(product, brand.getName()); } @@ -57,13 +60,14 @@ public Page findAll(Long brandId, Pageable pageable) { public ProductInfo update(ProductUpdateCommand command) { Product product = productService.update( command.id(), command.name(), command.price(), command.stock()); + eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약 String brandName = brandService.findById(product.getBrandId()).getName(); return ProductInfo.from(product, brandName); } /** * 상품 삭제 (US-P07) - * 좋아요(hard delete) → 상품(soft delete) 순서로 처리 + * 좋아요(hard delete) → 상품(soft delete) → 커밋 후 캐시 무효화 순서로 처리 */ @Transactional public void delete(Long id) { @@ -73,5 +77,6 @@ public void delete(Long id) { likeService.deleteAllByProductId(id); // 상품 soft delete (이미 managed 상태이므로 dirty checking으로 처리) product.delete(); + eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약 } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java new file mode 100644 index 000000000..053788805 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java @@ -0,0 +1,24 @@ +package com.loopers.application.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +public class ProductCacheEventListener { + + private final ProductCacheRepository productCacheRepository; + + /** + * DB 트랜잭션 커밋 완료 후에 캐시 무효화 실행. + * BEFORE_COMMIT이 아닌 AFTER_COMMIT을 사용하는 이유: + * 커밋 전에 캐시를 삭제하면, 삭제~커밋 사이 구간에 다른 요청이 + * 캐시 미스 → DB 조회(구 버전) → 구 버전 재캐싱하는 레이스가 발생하기 때문. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductChanged(ProductCacheEvictEvent event) { + productCacheRepository.evictAll(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.java new file mode 100644 index 000000000..5788345d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +/** + * 상품 데이터 변경 시 캐시 무효화를 요청하는 이벤트. + * 트랜잭션 커밋 후(AFTER_COMMIT)에 처리되어 + * "미커밋 상태에서 구 버전 재캐싱" 레이스 컨디션을 방지한다. + */ +public record ProductCacheEvictEvent() { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 67299f18c..1330a9269 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.product; +import com.loopers.config.CacheProperties; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; @@ -11,6 +12,7 @@ import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -18,6 +20,8 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ProductCacheRepository productCacheRepository; + private final CacheProperties cacheProperties; // 상품 상세 조회 @Transactional(readOnly = true) @@ -27,12 +31,45 @@ public ProductInfo findById(Long id) { return ProductInfo.from(product, brandName); } - // 상품 목록 조회 (brandId 필터 선택) + // 상품 목록 조회 (Cache-Aside 패턴) @Transactional(readOnly = true) - public Page findAll(Long brandId, Pageable pageable) { + public ProductPageResult findAll(Long brandId, Pageable pageable) { + // 딥 페이징 구간은 캐시 미적용 → DB 직접 조회 + if (!cacheProperties.isCacheable(pageable.getPageNumber())) { + return fetchFromDb(brandId, pageable); + } + + String cacheKey = buildCacheKey(brandId, pageable); + + // 캐시 히트: 즉시 반환 + return productCacheRepository.getList(cacheKey).orElseGet(() -> { + // 캐시 미스: DB 조회 후 캐시에 저장 + ProductPageResult result = fetchFromDb(brandId, pageable); + productCacheRepository.saveList(cacheKey, result); + return result; + }); + } + + private ProductPageResult fetchFromDb(Long brandId, Pageable pageable) { Page products = productService.findAll(brandId, pageable); List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); Map brandNameMap = brandService.findNamesByIds(brandIds); - return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))); + Page infoPage = products.map( + product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId())) + ); + return ProductPageResult.from(infoPage); + } + + /** + * 캐시 키 레이어링 전략: {서비스}:{도메인}:{오퍼레이션}:{파라미터} + * 예) loopers:product:list:brand=1:sort=createdAt_DESC:page=0:size=20 + */ + private String buildCacheKey(Long brandId, Pageable pageable) { + String brandPart = brandId == null ? "brand=all" : "brand=" + brandId; + String sortPart = pageable.getSort().stream() + .map(order -> order.getProperty() + "_" + order.getDirection()) + .collect(Collectors.joining(",")); + return String.format("loopers:product:list:%s:sort=%s:page=%d:size=%d", + brandPart, sortPart, pageable.getPageNumber(), pageable.getPageSize()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java b/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java index 636f4c277..2c3b1eaae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java @@ -4,16 +4,21 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; /** * cache.* 설정을 타입 안전하게 바인딩. * default-ttl-seconds: 전역 기본 TTL * ttl-overrides: 캐시 이름별 TTL 재정의 (예: product:list → 180초) + * jitter-range-seconds: TTL에 ±N초 무작위 오차를 더해 동시 만료 방지 (캐시 스탬피드 완화) + * max-cacheable-page: 이 페이지 번호 미만(0-indexed)만 캐싱 - 딥 페이징 캐시 키 폭발 방지 */ @ConfigurationProperties(prefix = "cache") public record CacheProperties( long defaultTtlSeconds, - Map ttlOverrides + Map ttlOverrides, + long jitterRangeSeconds, + int maxCacheablePage ) { public CacheProperties { if (ttlOverrides == null) { @@ -22,6 +27,17 @@ public record CacheProperties( } public long getTtlSeconds(String cacheName) { - return ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds); + long base = ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds); + if (jitterRangeSeconds <= 0) { + return base; + } + // Bounded Jitter: base ± jitterRange 범위에서 무작위 TTL + long jitter = ThreadLocalRandom.current().nextLong(-jitterRangeSeconds, jitterRangeSeconds + 1); + return Math.max(1, base + jitter); // 최소 1초 보장 + } + + // page=0, 1, 2 → 캐싱 대상 / page=3 이상 → DB 직접 조회 + public boolean isCacheable(int pageNumber) { + return pageNumber < maxCacheablePage; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index db64ef590..33ca552ec 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,10 +1,8 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,7 +26,7 @@ public ApiResponse getProducts( @RequestParam(defaultValue = "20") int size) { var pageable = PageRequest.of(page, size, ProductSortType.from(sort).toSort()); - Page result = productFacade.findAll(brandId, pageable); + var result = productFacade.findAll(brandId, pageable); return ApiResponse.success(ProductV1Dto.ProductListResponse.from(result)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 3ca2722fd..264c8c18f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductInfo; -import org.springframework.data.domain.Page; +import com.loopers.application.product.ProductPageResult; import java.util.List; @@ -34,13 +34,13 @@ public record ProductListResponse( long totalElements, int totalPages ) { - public static ProductListResponse from(Page info) { + public static ProductListResponse from(ProductPageResult result) { return new ProductListResponse( - info.getContent().stream().map(ProductResponse::from).toList(), - info.getNumber(), - info.getSize(), - info.getTotalElements(), - info.getTotalPages() + result.products().stream().map(ProductResponse::from).toList(), + result.page(), + result.size(), + result.totalElements(), + result.totalPages() ); } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..d28d1c0c8 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -29,6 +29,13 @@ springdoc: swagger-ui: path: /swagger-ui.html +cache: + default-ttl-seconds: 300 # 기본 TTL 5분 + jitter-range-seconds: 30 # ±30초 무작위 오차 → 동시 만료 방지 (캐시 스탬피드 완화) + max-cacheable-page: 3 # page=0,1,2 만 캐싱 (딥 페이징 캐시 키 폭발 방지) + ttl-overrides: + product:list: 180 # 상품 목록 캐시 TTL 3분 (좋아요 수 변경 허용 stale 범위) + --- spring: config: From 6f86caeeae046933fb789ed1ecca79a7baed54e4 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 08:49:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[test]=20:=20=EC=BA=90=EC=8B=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9/=EB=AF=B8=EC=A0=81=EC=9A=A9=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20k6=20=EB=B6=80=ED=95=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=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 Sonnet 4.6 --- scripts/cache_test.js | 47 ++++++++++++++++++++++++++++++++++++ scripts/report_cached.html | 43 +++++++++++++++++++++++++++++++++ scripts/report_uncached.html | 43 +++++++++++++++++++++++++++++++++ scripts/test_cached.js | 32 ++++++++++++++++++++++++ scripts/test_extreme.js | 43 +++++++++++++++++++++++++++++++++ scripts/test_uncached.js | 32 ++++++++++++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 scripts/cache_test.js create mode 100644 scripts/report_cached.html create mode 100644 scripts/report_uncached.html create mode 100644 scripts/test_cached.js create mode 100644 scripts/test_extreme.js create mode 100644 scripts/test_uncached.js diff --git a/scripts/cache_test.js b/scripts/cache_test.js new file mode 100644 index 000000000..d132e8694 --- /dev/null +++ b/scripts/cache_test.js @@ -0,0 +1,47 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; + +// 캐시 히트/미스 응답 시간을 별도 측정 +const cachedDuration = new Trend('cached_page_duration', true); +const uncachedDuration = new Trend('uncached_page_duration', true); +const cachedErrors = new Counter('cached_page_errors'); +const uncachedErrors = new Counter('uncached_page_errors'); + +export const options = { + stages: [ + { duration: '10s', target: 30 }, // 30명까지 점진 증가 + { duration: '30s', target: 30 }, // 30명 유지 + { duration: '10s', target: 0 }, // 종료 + ], + thresholds: { + cached_page_duration: ['p(95)<100'], // 캐시 히트는 95%가 100ms 이내 목표 + uncached_page_duration: ['p(95)<2000'], // 캐시 미스(딥 페이징)는 2000ms 허용 + }, +}; + +const BASE_URL = 'http://localhost:8080'; + +export default function () { + // ── 캐시 적용 대상 (page=0~2) ────────────────────────── + const r1 = http.get(`${BASE_URL}/api/v1/products?page=0&sort=latest`); + cachedDuration.add(r1.timings.duration); + if (!check(r1, { 'cached page=0 status 200': (r) => r.status === 200 })) { + cachedErrors.add(1); + } + + const r2 = http.get(`${BASE_URL}/api/v1/products?page=1&sort=latest`); + cachedDuration.add(r2.timings.duration); + if (!check(r2, { 'cached page=1 status 200': (r) => r.status === 200 })) { + cachedErrors.add(1); + } + + // ── 캐시 미적용 대상 (page=10, 딥 페이징) ────────────── + const r3 = http.get(`${BASE_URL}/api/v1/products?page=10&sort=latest`); + uncachedDuration.add(r3.timings.duration); + if (!check(r3, { 'uncached page=10 status 200': (r) => r.status === 200 })) { + uncachedErrors.add(1); + } + + sleep(0.5); +} diff --git a/scripts/report_cached.html b/scripts/report_cached.html new file mode 100644 index 000000000..de49e37ce --- /dev/null +++ b/scripts/report_cached.html @@ -0,0 +1,43 @@ + + + + + + + + + k6 report + + + + + +
+ + + diff --git a/scripts/report_uncached.html b/scripts/report_uncached.html new file mode 100644 index 000000000..27eef50bd --- /dev/null +++ b/scripts/report_uncached.html @@ -0,0 +1,43 @@ + + + + + + + + + k6 report + + + + + +
+ + + diff --git a/scripts/test_cached.js b/scripts/test_cached.js new file mode 100644 index 000000000..a59ec39fb --- /dev/null +++ b/scripts/test_cached.js @@ -0,0 +1,32 @@ +/** + * 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간) + * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const duration = new Trend('response_duration', true); + +export const options = { + stages: [ + { duration: '20s', target: 100 }, // 100명까지 증가 + { duration: '30s', target: 100 }, // 100명 유지 + { duration: '20s', target: 200 }, // 200명까지 증가 + { duration: '30s', target: 200 }, // 200명 유지 + { duration: '20s', target: 300 }, // 300명까지 증가 + { duration: '30s', target: 300 }, // 300명 유지 + { duration: '10s', target: 0 }, // 종료 + ], + thresholds: { + response_duration: ['p(95)<200'], + http_req_failed: ['rate<0.01'], // 에러율 1% 미만 + }, +}; + +export default function () { + const res = http.get('http://localhost:8080/api/v1/products?page=0&sort=latest'); + duration.add(res.timings.duration); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(0.5); +} diff --git a/scripts/test_extreme.js b/scripts/test_extreme.js new file mode 100644 index 000000000..264ce79da --- /dev/null +++ b/scripts/test_extreme.js @@ -0,0 +1,43 @@ +/** + * 극단 부하 테스트 - 캐시 적용/미적용 비교용 + * 실행 전: ulimit -n 10000 + * + * [캐시 미적용] page=10 테스트: + * docker exec redis-master redis-cli FLUSHALL && \ + * K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=scripts/report_extreme_uncached.html \ + * k6 run -e TARGET_PAGE=10 scripts/test_extreme.js + * + * [캐시 적용] page=0 테스트: + * docker exec redis-master redis-cli FLUSHALL && \ + * K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=scripts/report_extreme_cached.html \ + * k6 run -e TARGET_PAGE=0 scripts/test_extreme.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const duration = new Trend('response_duration', true); +const PAGE = __ENV.TARGET_PAGE || '0'; + +export const options = { + stages: [ + { duration: '10s', target: 100 }, // 10초 안에 100명 + { duration: '20s', target: 100 }, // 100명 유지 + { duration: '10s', target: 300 }, // 10초 안에 300명 + { duration: '20s', target: 300 }, // 300명 유지 + { duration: '10s', target: 500 }, // 10초 안에 500명 (극단) + { duration: '20s', target: 500 }, // 500명 유지 + { duration: '10s', target: 0 }, // 종료 + ], + thresholds: { + response_duration: ['p(95)<5000'], // 5초 기준 (초과 여부 관찰용) + http_req_failed: ['rate<0.10'], // 에러율 10% 미만 + }, +}; + +export default function () { + const res = http.get(`http://localhost:8080/api/v1/products?page=${PAGE}&sort=latest`); + duration.add(res.timings.duration); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(0.3); +} diff --git a/scripts/test_uncached.js b/scripts/test_uncached.js new file mode 100644 index 000000000..6f4647556 --- /dev/null +++ b/scripts/test_uncached.js @@ -0,0 +1,32 @@ +/** + * 캐시 미적용 페이지 부하 테스트 (page=10 - 항상 DB 직접 조회) + * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_uncached.js + */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const duration = new Trend('response_duration', true); + +export const options = { + stages: [ + { duration: '20s', target: 100 }, // 100명까지 증가 + { duration: '30s', target: 100 }, // 100명 유지 + { duration: '20s', target: 200 }, // 200명까지 증가 + { duration: '30s', target: 200 }, // 200명 유지 + { duration: '20s', target: 300 }, // 300명까지 증가 + { duration: '30s', target: 300 }, // 300명 유지 + { duration: '10s', target: 0 }, // 종료 + ], + thresholds: { + response_duration: ['p(95)<2000'], + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + const res = http.get('http://localhost:8080/api/v1/products?page=10&sort=latest'); + duration.add(res.timings.duration); + check(res, { 'status 200': (r) => r.status === 200 }); + sleep(0.5); +} From 33f03e8a08498f8c7ed12e17f2a0eb7fa5c8230d Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 16:16:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=BA=90=EC=8B=9C=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4/=EA=B5=AC=ED=98=84=EC=B2=B4/=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductDetailCacheEvictEvent: 상품 ID 기반 상세 캐시 핀포인트 무효화 이벤트 추가 - ProductCacheRepository: 상세 캐시 인터페이스(getDetail/saveDetail/evictDetail) 추가 - ProductCacheRepositoryImpl: 상세 캐시 구현 (키: loopers:product:detail:{id}, Replica 읽기 / Master 쓰기) - ProductCacheEventListener: ProductDetailCacheEvictEvent 핸들러 추가 (AFTER_COMMIT) - application.yml: product:detail TTL 60초 설정 (민감 정보는 이벤트 무효화, likeCount stale 허용 범위) Co-Authored-By: Claude Sonnet 4.6 --- .../product/ProductCacheEventListener.java | 6 +++ .../product/ProductCacheRepository.java | 15 ++++++- .../product/ProductDetailCacheEvictEvent.java | 9 +++++ .../product/ProductCacheRepositoryImpl.java | 40 ++++++++++++++++++- .../src/main/resources/application.yml | 1 + 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java index 053788805..a48175dd0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java @@ -21,4 +21,10 @@ public class ProductCacheEventListener { public void handleProductChanged(ProductCacheEvictEvent event) { productCacheRepository.evictAll(); } + + // 특정 상품의 상세 캐시만 핀포인트 무효화 (주문 재고 차감, 상품/브랜드 수정 시 호출) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductDetailChanged(ProductDetailCacheEvictEvent event) { + productCacheRepository.evictDetail(event.productId()); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java index e3ba5f7dc..867f3eda2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java @@ -3,11 +3,13 @@ import java.util.Optional; /** - * 상품 목록 캐시 포트 (Secondary Port / Driven Port). + * 상품 캐시 포트 (Secondary Port / Driven Port). * application 레이어는 이 인터페이스에만 의존 → Redis 등 구현 기술이 바뀌어도 Facade 코드 변경 없음. */ public interface ProductCacheRepository { + // ── 목록 캐시 ──────────────────────────────────────────────────────────── + // 캐시 조회 - 미스 시 Optional.empty() 반환 (예외 미전파) Optional getList(String cacheKey); @@ -16,4 +18,15 @@ public interface ProductCacheRepository { // 상품 등록/수정/삭제 시 목록 캐시 전체 무효화 void evictAll(); + + // ── 상세 캐시 ──────────────────────────────────────────────────────────── + + // 상세 캐시 조회 - 미스 시 Optional.empty() 반환 + Optional getDetail(Long productId); + + // 상세 캐시 저장 (목록 페이지 워밍업 시 호출) + void saveDetail(Long productId, ProductInfo info); + + // 특정 상품 상세 캐시 무효화 + void evictDetail(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java new file mode 100644 index 000000000..6bb66d633 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java @@ -0,0 +1,9 @@ +package com.loopers.application.product; + +/** + * 특정 상품 상세 캐시를 무효화하는 이벤트. + * 재고 차감(주문), 상품 정보 수정/삭제, 브랜드명 수정 등 민감한 데이터 변경 시 발행. + * AFTER_COMMIT에서 처리되어 커밋 전 구 버전 재캐싱 레이스를 방지한다. + */ +public record ProductDetailCacheEvictEvent(Long productId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java index 793484b81..411b70517 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.product.ProductCacheRepository; +import com.loopers.application.product.ProductInfo; import com.loopers.application.product.ProductPageResult; import com.loopers.config.CacheProperties; import lombok.extern.slf4j.Slf4j; @@ -24,7 +25,8 @@ @Repository public class ProductCacheRepositoryImpl implements ProductCacheRepository { - private static final String CACHE_KEY_PATTERN = "loopers:product:list:*"; + private static final String LIST_CACHE_KEY_PATTERN = "loopers:product:list:*"; + private static final String DETAIL_KEY_PREFIX = "loopers:product:detail:"; private final RedisTemplate readTemplate; private final RedisTemplate writeTemplate; @@ -74,7 +76,7 @@ public void evictAll() { // KEYS는 O(N)으로 Redis를 블로킹하므로, SCAN으로 안전하게 순회 writeTemplate.execute((RedisCallback) connection -> { ScanOptions options = ScanOptions.scanOptions() - .match(CACHE_KEY_PATTERN) + .match(LIST_CACHE_KEY_PATTERN) .count(100) .build(); try (var cursor = connection.scan(options)) { @@ -90,4 +92,38 @@ public void evictAll() { log.warn("상품 목록 캐시 무효화 실패: {}", e.getMessage()); } } + + @Override + public Optional getDetail(Long productId) { + try { + String json = readTemplate.opsForValue().get(DETAIL_KEY_PREFIX + productId); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, ProductInfo.class)); + } catch (Exception e) { + log.warn("상품 상세 캐시 조회 실패 (productId={}): {}", productId, e.getMessage()); + return Optional.empty(); + } + } + + @Override + public void saveDetail(Long productId, ProductInfo info) { + try { + String json = objectMapper.writeValueAsString(info); + long ttlSeconds = cacheProperties.getTtlSeconds("product:detail"); + writeTemplate.opsForValue().set(DETAIL_KEY_PREFIX + productId, json, Duration.ofSeconds(ttlSeconds)); + } catch (Exception e) { + log.warn("상품 상세 캐시 저장 실패 (productId={}): {}", productId, e.getMessage()); + } + } + + @Override + public void evictDetail(Long productId) { + try { + writeTemplate.delete(DETAIL_KEY_PREFIX + productId); + } catch (Exception e) { + log.warn("상품 상세 캐시 무효화 실패 (productId={}): {}", productId, e.getMessage()); + } + } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index d28d1c0c8..624cf135c 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -35,6 +35,7 @@ cache: max-cacheable-page: 3 # page=0,1,2 만 캐싱 (딥 페이징 캐시 키 폭발 방지) ttl-overrides: product:list: 180 # 상품 목록 캐시 TTL 3분 (좋아요 수 변경 허용 stale 범위) + product:detail: 60 # 상품 상세 캐시 TTL 1분 (민감 정보는 이벤트로 즉시 무효화, TTL은 likeCount stale 허용 범위) --- spring: From 395c9f3ceb4676c0699aa12a4ae88fb80c4c0005 Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 16:17:06 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[feat]=20:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=BA=90=EC=8B=9C=20Cache-Aside=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductFacade.findById(): Cache-Aside 패턴 적용 (미스 시 DB 조회 후 상세 캐시 저장) - ProductAdminFacade: 상품 수정/삭제 시 상세 캐시 무효화 이벤트 발행 - OrderFacade: 주문 재고 차감 후 해당 상품 상세 캐시 무효화 이벤트 발행 - BrandAdminFacade: 브랜드명 수정/삭제 시 목록 캐시 + 해당 브랜드 상품 상세 캐시 무효화 이벤트 발행 (기존 누락 보완) Co-Authored-By: Claude Sonnet 4.6 --- .../loopers/application/brand/BrandAdminFacade.java | 11 +++++++++++ .../com/loopers/application/order/OrderFacade.java | 5 +++++ .../application/product/ProductAdminFacade.java | 6 ++++-- .../loopers/application/product/ProductFacade.java | 12 ++++++++---- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java index 389afad73..2961cc37e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java @@ -1,10 +1,13 @@ package com.loopers.application.brand; +import com.loopers.application.product.ProductCacheEvictEvent; +import com.loopers.application.product.ProductDetailCacheEvictEvent; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -18,6 +21,7 @@ public class BrandAdminFacade { private final BrandService brandService; private final ProductService productService; private final LikeService likeService; + private final ApplicationEventPublisher eventPublisher; // 브랜드 등록 @Transactional @@ -40,9 +44,13 @@ public Page findAll(Pageable pageable){ } // 브랜드 정보 수정 + // 브랜드명은 상품 목록/상세 캐시에 스냅샷되므로, 수정 시 해당 브랜드 상품 캐시 전체 무효화 @Transactional public BrandInfo update(BrandUpdateCommand command){ Brand brand = brandService.update(command.id(), command.name()); + List productIds = productService.findIdsByBrandId(command.id()); + eventPublisher.publishEvent(new ProductCacheEvictEvent()); + productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid))); return BrandInfo.from(brand); } @@ -61,5 +69,8 @@ public void delete(Long id){ productService.deleteAllByBrandId(id); // 브랜드 soft delete brand.delete(); + // 삭제된 상품들의 목록/상세 캐시 무효화 + eventPublisher.publishEvent(new ProductCacheEvictEvent()); + productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid))); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 68195be3f..09b191bb7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.order; +import com.loopers.application.product.ProductDetailCacheEvictEvent; import com.loopers.domain.brand.BrandService; import com.loopers.domain.coupon.CouponService; import com.loopers.domain.order.Order; @@ -12,6 +13,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +34,7 @@ public class OrderFacade { private final ProductService productService; private final BrandService brandService; private final CouponService couponService; + private final ApplicationEventPublisher eventPublisher; /** * 주문 생성 (US-O01) @@ -90,6 +93,8 @@ public OrderInfo create(Long userId, OrderCreateCommand command) { Quantity quantity = quantityByProductId.get(product.getId()); product.decreaseStock(quantity); } + // 재고 변동 → 주문된 각 상품 상세 캐시 즉시 무효화 (커밋 후 처리) + products.forEach(p -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(p.getId()))); // 브랜드명 일괄 조회 (스냅샷용) List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java index 179fc9103..2a16b8a41 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java @@ -60,7 +60,8 @@ public Page findAll(Long brandId, Pageable pageable) { public ProductInfo update(ProductUpdateCommand command) { Product product = productService.update( command.id(), command.name(), command.price(), command.stock()); - eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약 + eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 목록 캐시 전체 무효화 + eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(product.getId())); // 상세 캐시 핀포인트 무효화 String brandName = brandService.findById(product.getBrandId()).getName(); return ProductInfo.from(product, brandName); } @@ -77,6 +78,7 @@ public void delete(Long id) { likeService.deleteAllByProductId(id); // 상품 soft delete (이미 managed 상태이므로 dirty checking으로 처리) product.delete(); - eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약 + eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 목록 캐시 전체 무효화 + eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(id)); // 상세 캐시 핀포인트 무효화 } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1330a9269..a95608da4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -23,12 +23,16 @@ public class ProductFacade { private final ProductCacheRepository productCacheRepository; private final CacheProperties cacheProperties; - // 상품 상세 조회 + // 상품 상세 조회 (Cache-Aside) @Transactional(readOnly = true) public ProductInfo findById(Long id) { - Product product = productService.findById(id); - String brandName = brandService.findById(product.getBrandId()).getName(); - return ProductInfo.from(product, brandName); + return productCacheRepository.getDetail(id).orElseGet(() -> { + Product product = productService.findById(id); + String brandName = brandService.findById(product.getBrandId()).getName(); + ProductInfo info = ProductInfo.from(product, brandName); + productCacheRepository.saveDetail(id, info); + return info; + }); } // 상품 목록 조회 (Cache-Aside 패턴) From 6f5f5dcde3244237b6639d063239d68a04fae0be Mon Sep 17 00:00:00 2001 From: Namjin-kimm Date: Fri, 13 Mar 2026 16:17:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[docs]=20:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EB=B3=B4=EA=B3=A0=EC=84=9C=EC=97=90=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EC=B1=84=ED=83=9D=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20=EC=84=A0=ED=83=9D=20=EC=9D=B4=EC=9C=A0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채택 4개(idx_brand_deleted_created/price/likes, idx_deleted_created)와 미채택 2개(idx_deleted_price/likes)의 선택 근거 명시 - 결정 기준: 브랜드 필터 유무별 사용 빈도, 선택도, 쓰기 비용 트레이드오프 Co-Authored-By: Claude Sonnet 4.6 --- .docs/product-index-analysis.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.docs/product-index-analysis.md b/.docs/product-index-analysis.md index 0bee9632e..6cbcb1b3d 100644 --- a/.docs/product-index-analysis.md +++ b/.docs/product-index-analysis.md @@ -371,6 +371,39 @@ ALTER TABLE product ADD INDEX idx_deleted_created (deleted_at, created_at DESC); --- +## 14. 최종 채택 인덱스 및 선택 이유 + +### 채택된 인덱스 (4개) + +```java +// Product.java @Table indexes +@Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"), +@Index(name = "idx_brand_deleted_price", columnList = "brand_id, deleted_at, price"), +@Index(name = "idx_brand_deleted_likes", columnList = "brand_id, deleted_at, like_count DESC"), +@Index(name = "idx_deleted_created", columnList = "deleted_at, created_at DESC") +``` + +| 인덱스 | 채택 이유 | +|-------|---------| +| `idx_brand_deleted_created` | 브랜드 필터 + 최신순은 e커머스의 가장 일반적인 진입 패턴. `brand_id` 선두 컬럼이 선택도를 높여 실질적인 스캔 범위 축소 효과가 크다. | +| `idx_brand_deleted_price` | 브랜드 필터 + 가격순은 구매 전 비교 유즈케이스에서 빈번. `(brand_id, deleted_at)`으로 Q8(COUNT) 커버링 인덱스 효과도 겸한다. | +| `idx_brand_deleted_likes` | 브랜드 인기순 정렬 지원. `like_count` 갱신마다 B-Tree 갱신이 발생하는 쓰기 비용을 감수하고 채택 — 브랜드 페이지에서 인기순 정렬 수요가 충분하다고 판단했다. | +| `idx_deleted_created` | 브랜드 필터 없는 전체 최신순은 랜딩 페이지 기본 정렬로 트래픽이 가장 높다. `deleted_at IS NULL` 선택도가 낮아 효과가 제한적이나, filesort 제거만으로도 p95 응답 시간을 개선하기에 충분하다. | + +### 미채택 인덱스 (2개) 및 이유 + +| 인덱스 | 미채택 이유 | +|-------|-----------| +| `idx_deleted_price` | 브랜드 필터 없는 전체 가격순은 사용 빈도가 낮다고 판단. `deleted_at IS NULL` 선택도가 낮아 스캔 범위 축소 효과도 제한적이다. 쓰기 비용 대비 조회 이득이 크지 않아 제외. | +| `idx_deleted_likes` | 브랜드 필터 없는 전체 좋아요순은 역시 낮은 사용 빈도로 판단. `like_count`는 좋아요 생성/취소마다 갱신되는 핫스팟 컬럼이므로, 전체 조회용으로까지 인덱스를 두는 것은 쓰기 부하 대비 실익이 없다. | + +### 결정 요약 + +> 브랜드 필터가 있는 3가지 정렬(최신/가격/좋아요)은 선택도가 높아 인덱스 효과가 확실하므로 전부 채택. +> 브랜드 필터가 없는 경우는 랜딩 페이지 기본값인 최신순 1개만 채택 — 나머지 둘은 사용 빈도와 쓰기 비용을 고려해 제외. + +--- + *분석 기준일: 2026-03-12* *데이터 건수: product 테이블 100,000건* *테스트 방식: 인덱스를 1개씩 개별 추가/DROP, 버퍼풀 워밍업(2회) 후 측정*