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> multiPutCaptor; + + @BeforeEach + void setUp() { + productReader = new ProductReader(cacheRepository, productService); + } + + @DisplayName("활성 상품 목록을 조회할 때,") + @Nested + class ReadActiveProducts { + + @DisplayName("목록 캐시 HIT + 상세 캐시 전부 HIT이면, DB를 호출하지 않는다.") + @Test + void returnsFromCache_whenListAndDetailAllHit() { + // arrange + var pageSize = new PageSize(0, 20); + var product1 = ProductFixture.createProduct(1L); + var product2 = ProductFixture.createProduct(2L); + var idPage = new ProductReader.ProductIdPage(List.of(1L, 2L), true); + + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(idPage); + given(cacheRepository.multiGet(anyList(), any(CacheType.class))) + .willReturn(List.of(product1, product2)); + + // act + Page result = productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + assertAll( + () -> assertThat(result.content()).containsExactly(product1, product2), + () -> assertThat(result.hasNext()).isTrue() + ); + then(productService).shouldHaveNoInteractions(); + } + + @DisplayName("목록 캐시 HIT + 상세 캐시 부분 MISS이면, 미스된 상품만 DB에서 조회하고 multiPut으로 캐싱한다.") + @Test + void fetchesMissingFromDb_whenPartialDetailMiss() { + // arrange + var pageSize = new PageSize(0, 20); + var product1 = ProductFixture.createProduct(1L); + var product3 = ProductFixture.createProduct(3L); + var idPage = new ProductReader.ProductIdPage(List.of(1L, 2L, 3L), false); + + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(idPage); + given(cacheRepository.multiGet(anyList(), any(CacheType.class))) + .willReturn(Arrays.asList(product1, null, product3)); + + var product2 = ProductFixture.createProduct(2L); + given(productService.getActiveProductsByIds(List.of(2L))) + .willReturn(Map.of(2L, product2)); + + // act + var result = productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + assertAll( + () -> assertThat(result.content()).hasSize(3), + () -> assertThat(result.content().get(0).getId()).isEqualTo(1L), + () -> assertThat(result.content().get(1).getId()).isEqualTo(2L), + () -> assertThat(result.content().get(2).getId()).isEqualTo(3L), + () -> assertThat(result.hasNext()).isFalse() + ); + then(cacheRepository).should().multiPut(multiPutCaptor.capture(), any(Supplier.class)); + assertThat(multiPutCaptor.getValue()).containsKey(ProductCacheConstants.DETAIL_KEY.of(2L)); + } + + @DisplayName("목록 캐시 MISS이면, 락을 획득하고 DB에서 조회한 뒤 양쪽 캐시에 저장한다.") + @Test + void fetchesFromDbAndCachesBoth_whenListCacheMiss() { + // arrange + var pageSize = new PageSize(0, 20); + var product1 = ProductFixture.createProduct(1L); + var product2 = ProductFixture.createProduct(2L); + var dbPage = new Page<>(List.of(product1, product2), true); + + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(null); + given(productService.getActiveProducts(null, ProductSortType.DEFAULT, pageSize)).willReturn(dbPage); + + // act + var result = productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + assertAll( + () -> assertThat(result.content()).containsExactly(product1, product2), + () -> assertThat(result.hasNext()).isTrue() + ); + then(cacheRepository).should().put(anyString(), any(ProductReader.ProductIdPage.class), any(Duration.class)); + then(cacheRepository).should().multiPut(multiPutCaptor.capture(), any(Supplier.class)); + Map cached = multiPutCaptor.getValue(); + assertAll( + () -> assertThat(cached).containsKey(ProductCacheConstants.DETAIL_KEY.of(1L)), + () -> assertThat(cached).containsKey(ProductCacheConstants.DETAIL_KEY.of(2L)) + ); + } + + @DisplayName("목록 캐시 MISS + 락 대기 후 캐시 HIT이면, DB를 호출하지 않고 캐시에서 반환한다.") + @Test + void returnsCacheAfterLockWait_whenCachePopulatedByOtherThread() { + // arrange + var pageSize = new PageSize(0, 20); + var product1 = ProductFixture.createProduct(1L); + var idPage = new ProductReader.ProductIdPage(List.of(1L), false); + + given(cacheRepository.get(anyString(), any(CacheType.class))) + .willReturn(null) + .willReturn(idPage); + given(cacheRepository.multiGet(anyList(), any(CacheType.class))) + .willReturn(List.of(product1)); + + // act + var result = productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + assertAll( + () -> assertThat(result.content()).containsExactly(product1), + () -> assertThat(result.hasNext()).isFalse() + ); + then(productService).shouldHaveNoInteractions(); + then(cacheRepository).should(times(2)).get(anyString(), any(CacheType.class)); + } + + @DisplayName("캐시 불가 페이지(page > 2)이면, 목록 캐시를 저장하지 않지만 상세 캐시는 저장한다.") + @Test + void doesNotCacheList_whenPageExceedsMax() { + // arrange + var pageSize = new PageSize(3, 20); + var product1 = ProductFixture.createProduct(1L); + var dbPage = new Page<>(List.of(product1), false); + + given(productService.getActiveProducts(null, ProductSortType.DEFAULT, pageSize)).willReturn(dbPage); + + // act + productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + then(cacheRepository).should(never()).put(anyString(), any(ProductReader.ProductIdPage.class), any(Duration.class)); + then(cacheRepository).should().multiPut(multiPutCaptor.capture(), any(Supplier.class)); + assertThat(multiPutCaptor.getValue()).containsKey(ProductCacheConstants.DETAIL_KEY.of(1L)); + } + } + + @DisplayName("Lock 메모리 관리를 할 때,") + @Nested + class LockCleanup { + + @DisplayName("목록 캐시 MISS로 락을 사용한 뒤, 락이 map에서 제거된다.") + @Test + void removesLockAfterListCacheMiss() { + // arrange + var pageSize = new PageSize(0, 20); + var product1 = ProductFixture.createProduct(1L); + var dbPage = new Page<>(List.of(product1), false); + + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(null); + given(productService.getActiveProducts(null, ProductSortType.DEFAULT, pageSize)).willReturn(dbPage); + + // act + productReader.readActiveProducts(null, ProductSortType.DEFAULT, pageSize); + + // assert + assertThat(productReader.lockCount()).isZero(); + } + + @DisplayName("상세 캐시 MISS로 락을 사용한 뒤, 락이 map에서 제거된다.") + @Test + void removesLockAfterDetailCacheMiss() { + // arrange + var product = ProductFixture.createProduct(1L); + String detailKey = ProductCacheConstants.DETAIL_KEY.of(1L); + + given(cacheRepository.get(eq(detailKey), any(CacheType.class))).willReturn(null); + given(productService.getActiveProduct(1L)).willReturn(product); + + // act + productReader.readActiveProduct(1L); + + // assert + assertThat(productReader.lockCount()).isZero(); + } + + @DisplayName("여러 키로 락을 사용한 뒤, 모두 제거된다.") + @Test + void removesAllLocksAfterMultipleCalls() { + // arrange + var product1 = ProductFixture.createProduct(1L); + var product2 = ProductFixture.createProduct(2L); + + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(null); + given(productService.getActiveProduct(1L)).willReturn(product1); + given(productService.getActiveProduct(2L)).willReturn(product2); + + // act + productReader.readActiveProduct(1L); + productReader.readActiveProduct(2L); + + // assert + assertThat(productReader.lockCount()).isZero(); + } + } + + @DisplayName("활성 상품 단건을 조회할 때,") + @Nested + class ReadActiveProduct { + + @DisplayName("캐시 HIT이면, DB를 호출하지 않는다.") + @Test + void returnsFromCache_whenCacheHit() { + // arrange + var product = ProductFixture.createProduct(1L); + given(cacheRepository.get(anyString(), any(CacheType.class))).willReturn(product); + + // act + var result = productReader.readActiveProduct(1L); + + // assert + assertThat(result).isEqualTo(product); + then(productService).shouldHaveNoInteractions(); + } + + @DisplayName("캐시 MISS이면, 락을 획득하고 DB에서 조회한 뒤 캐싱한다.") + @Test + void fetchesFromDbAndCaches_whenCacheMiss() { + // arrange + var product = ProductFixture.createProduct(1L); + String detailKey = ProductCacheConstants.DETAIL_KEY.of(1L); + + given(cacheRepository.get(eq(detailKey), any(CacheType.class))).willReturn(null); + given(productService.getActiveProduct(1L)).willReturn(product); + + // act + var result = productReader.readActiveProduct(1L); + + // assert + assertAll( + () -> assertThat(result).isEqualTo(product), + () -> then(cacheRepository).should().put(eq(detailKey), eq(product), any(Duration.class)) + ); + } + + @DisplayName("캐시 MISS + 락 대기 후 캐시 HIT이면, DB를 호출하지 않고 캐시에서 반환한다.") + @Test + void returnsCacheAfterLockWait_whenCachePopulatedByOtherThread() { + // arrange + var product = ProductFixture.createProduct(1L); + String detailKey = ProductCacheConstants.DETAIL_KEY.of(1L); + + given(cacheRepository.get(eq(detailKey), any(CacheType.class))) + .willReturn(null) + .willReturn(product); + + // act + var result = productReader.readActiveProduct(1L); + + // assert + assertAll( + () -> assertThat(result).isEqualTo(product), + () -> then(productService).shouldHaveNoInteractions(), + () -> then(cacheRepository).should(times(2)).get(eq(detailKey), any(CacheType.class)) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductWriterTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductWriterTest.java new file mode 100644 index 000000000..7bfc1ded3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductWriterTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.Duration; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.loopers.domain.shared.cache.CacheRepository; + +@ExtendWith(MockitoExtension.class) +class ProductWriterTest { + + @InjectMocks + private ProductWriter productWriter; + + @Mock + private ProductService productService; + + @Mock + private CacheRepository cacheRepository; + + @DisplayName("상품을 수정할 때,") + @Nested + class Update { + + @DisplayName("DB를 수정하고 상세 캐시를 overwrite한다. 목록 캐시(ID 리스트)는 evict하지 않는다.") + @Test + void updatesAndOverwritesDetailCache() { + // arrange + var modifyProduct = new ModifyProduct(1L, "수정된 상품명", "http://example.com/new.jpg", 20000L, 100L, "설명"); + var updatedProduct = Product.create(new ProductSpec(1L, "수정된 상품명", "http://example.com/new.jpg", 20000L, 100L, "설명")); + given(productService.update(modifyProduct)).willReturn(updatedProduct); + + // act + productWriter.update(modifyProduct); + + // assert + String detailKey = ProductCacheConstants.DETAIL_KEY.of(1L); + then(cacheRepository).should().put(eq(detailKey), eq(updatedProduct), any(Duration.class)); + then(cacheRepository).shouldHaveNoMoreInteractions(); + } + } + + @DisplayName("상품을 삭제할 때,") + @Nested + class Delete { + + @DisplayName("삭제에 성공하면, 목록 캐시 전체 + 해당 상세 캐시를 무효화한다.") + @Test + void deletesAndEvictsCache() { + // arrange + Long productId = 1L; + given(productService.delete(productId)).willReturn(true); + + // act + boolean result = productWriter.delete(productId); + + // assert + assertThat(result).isTrue(); + then(cacheRepository).should().evict(ProductCacheConstants.LIST_KEY.pattern()); + then(cacheRepository).should().evict(ProductCacheConstants.DETAIL_KEY.of(productId)); + } + + @DisplayName("이미 삭제된 상품이면, 캐시 무효화를 수행하지 않는다.") + @Test + void doesNotEvictCache_whenAlreadyDeleted() { + // arrange + Long productId = 1L; + given(productService.delete(productId)).willReturn(false); + + // act + boolean result = productWriter.delete(productId); + + // assert + assertThat(result).isFalse(); + then(cacheRepository).shouldHaveNoInteractions(); + } + } + + @DisplayName("좋아요 수를 증가시킬 때,") + @Nested + class IncreaseLikeCount { + + @DisplayName("DB를 갱신하고 DB에서 최신 상품을 조회하여 캐시를 갱신한다.") + @Test + void updatesDbAndRefreshesCache() { + // arrange + Long productId = 1L; + String detailKey = ProductCacheConstants.DETAIL_KEY.of(productId); + var product = Product.create(new ProductSpec(1L, "상품명", "http://example.com/thumb.jpg", 10000L, 50L, null)); + given(productService.getActiveProduct(productId)).willReturn(product); + + // act + productWriter.increaseLikeCount(productId); + + // assert + then(productService).should().increaseLikeCount(productId); + then(productService).should().getActiveProduct(productId); + then(cacheRepository).should().put(eq(detailKey), eq(product), any(Duration.class)); + } + } + + @DisplayName("좋아요 수를 감소시킬 때,") + @Nested + class DecreaseLikeCount { + + @DisplayName("DB를 갱신하고 DB에서 최신 상품을 조회하여 캐시를 갱신한다.") + @Test + void updatesDbAndRefreshesCache() { + // arrange + Long productId = 1L; + String detailKey = ProductCacheConstants.DETAIL_KEY.of(productId); + var product = Product.create(new ProductSpec(1L, "상품명", "http://example.com/thumb.jpg", 10000L, 50L, null)); + given(productService.getActiveProduct(productId)).willReturn(product); + + // act + productWriter.decreaseLikeCount(productId); + + // assert + then(productService).should().decreaseLikeCount(productId); + then(productService).should().getActiveProduct(productId); + then(cacheRepository).should().put(eq(detailKey), eq(product), any(Duration.class)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java new file mode 100644 index 000000000..9ca26e720 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/shared/cache/CacheKeyTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.shared.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CacheKeyTest { + + @DisplayName("캐시 키를 생성할 때,") + @Nested + class Of { + + @DisplayName("세그먼트를 구분자로 결합하여 키를 반환한다.") + @Test + void joinsSegmentsWithDelimiter() { + // arrange + CacheKey key = new CacheKey("product", "detail", "v1"); + + // act + String result = key.of(123); + + // assert + assertThat(result).isEqualTo("product:detail:v1:123"); + } + + @DisplayName("여러 세그먼트를 전달하면 모두 결합한다.") + @Test + void joinsMultipleSegments() { + // arrange + CacheKey key = new CacheKey("product", "list", "v1"); + + // act + String result = key.of("all", "LATEST", 1, 20); + + // assert + assertThat(result).isEqualTo("product:list:v1:all:LATEST:1:20"); + } + + @DisplayName("세그먼트 없이 호출하면 prefix만 반환한다.") + @Test + void returnsPrefixOnly_whenNoSegments() { + // arrange + CacheKey key = new CacheKey("product", "detail", "v1"); + + // act + String result = key.of(); + + // assert + assertThat(result).isEqualTo("product:detail:v1"); + } + } + + @DisplayName("와일드카드 패턴을 생성할 때,") + @Nested + class Pattern { + + @DisplayName("prefix 뒤에 :* 를 붙여 반환한다.") + @Test + void appendsWildcard() { + // arrange + CacheKey key = new CacheKey("product", "list", "v1"); + + // act + String result = key.pattern(); + + // assert + assertThat(result).isEqualTo("product:list:v1:*"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java new file mode 100644 index 000000000..65b090aaa --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/shared/cache/RedisCacheRepositoryIntegrationTest.java @@ -0,0 +1,258 @@ +package com.loopers.infrastructure.shared.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.loopers.domain.shared.cache.CacheType; +import com.loopers.support.page.Page; +import com.loopers.utils.RedisCleanUp; + +@SpringBootTest +class RedisCacheRepositoryIntegrationTest { + + private static final CacheType STRING_TYPE = new CacheType<>() {}; + private static final CacheType> PAGE_TYPE = new CacheType<>() {}; + + @Autowired + private RedisCacheRepository cacheRepository; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @DisplayName("캐시에 값을 저장하고 조회할 때,") + @Nested + class PutAndGet { + + @DisplayName("단순 타입을 저장하면, 동일한 값이 조회된다.") + @Test + void returnsStoredValue_whenSimpleTypePut() { + // arrange + String key = "test:simple"; + String value = "hello"; + + // act + cacheRepository.put(key, value); + String result = cacheRepository.get(key, STRING_TYPE); + + // assert + assertThat(result).isEqualTo("hello"); + } + + @DisplayName("파라미터화된 타입을 저장하면, 타입 정보가 보존되어 조회된다.") + @Test + void returnsStoredValue_whenParameterizedTypePut() { + // arrange + String key = "test:page"; + Page value = new Page<>( + List.of(new TestItem(1L, "item1"), new TestItem(2L, "item2")), + true + ); + + // act + cacheRepository.put(key, value); + Page result = cacheRepository.get(key, PAGE_TYPE); + + // assert + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.content().get(0).name()).isEqualTo("item1"), + () -> assertThat(result.hasNext()).isTrue() + ); + } + + @DisplayName("존재하지 않는 키를 조회하면, null이 반환된다.") + @Test + void returnsNull_whenKeyDoesNotExist() { + // act + String result = cacheRepository.get("test:nonexistent", STRING_TYPE); + + // assert + assertThat(result).isNull(); + } + } + + @DisplayName("TTL을 지정하여 저장할 때,") + @Nested + class PutWithTtl { + + @DisplayName("TTL이 만료되면, null이 반환된다.") + @Test + void returnsNull_whenTtlExpired() { + // arrange + String key = "test:ttl"; + cacheRepository.put(key, "expiring", Duration.ofSeconds(1)); + + // act & assert + await().atMost(Duration.ofSeconds(3)) + .pollInterval(Duration.ofMillis(200)) + .untilAsserted(() -> assertThat(cacheRepository.get(key, STRING_TYPE)).isNull()); + } + + @DisplayName("TTL이 만료되기 전이면, 값이 조회된다.") + @Test + void returnsValue_whenTtlNotExpired() { + // arrange + String key = "test:ttl-alive"; + cacheRepository.put(key, "still-alive", Duration.ofMinutes(1)); + + // act + String result = cacheRepository.get(key, STRING_TYPE); + + // assert + assertThat(result).isEqualTo("still-alive"); + } + } + + @DisplayName("캐시를 삭제할 때,") + @Nested + class Evict { + + @DisplayName("패턴에 매칭되는 키가 모두 삭제된다.") + @Test + void deletesAllMatchingKeys_whenPatternProvided() { + // arrange + cacheRepository.put("product:list:1", "a"); + cacheRepository.put("product:list:2", "b"); + cacheRepository.put("order:list:1", "c"); + + // act + cacheRepository.evict("product:list:*"); + + // assert + assertAll( + () -> assertThat(cacheRepository.get("product:list:1", STRING_TYPE)).isNull(), + () -> assertThat(cacheRepository.get("product:list:2", STRING_TYPE)).isNull(), + () -> assertThat(cacheRepository.get("order:list:1", STRING_TYPE)).isEqualTo("c") + ); + } + + @DisplayName("와일드카드 없는 단일 키를 지정하면, 해당 키만 삭제된다.") + @Test + void deletesSingleKey_whenExactKeyProvided() { + // arrange + cacheRepository.put("product:detail:1", "a"); + cacheRepository.put("product:detail:2", "b"); + + // act + cacheRepository.evict("product:detail:1"); + + // assert + assertAll( + () -> assertThat(cacheRepository.get("product:detail:1", STRING_TYPE)).isNull(), + () -> assertThat(cacheRepository.get("product:detail:2", STRING_TYPE)).isEqualTo("b") + ); + } + } + + @DisplayName("여러 키를 한 번에 조회할 때,") + @Nested + class MultiGet { + + @DisplayName("모든 키가 존재하면, 순서대로 값이 반환된다.") + @Test + void returnsAllValues_whenAllKeysExist() { + // arrange + cacheRepository.put("test:multi:1", "a"); + cacheRepository.put("test:multi:2", "b"); + cacheRepository.put("test:multi:3", "c"); + + // act + List result = cacheRepository.multiGet( + List.of("test:multi:1", "test:multi:2", "test:multi:3"), STRING_TYPE); + + // assert + assertThat(result).containsExactly("a", "b", "c"); + } + + @DisplayName("일부 키가 존재하지 않으면, 해당 위치에 null이 반환된다.") + @Test + void returnsNullForMissingKeys() { + // arrange + cacheRepository.put("test:multi:1", "a"); + + // act + List result = cacheRepository.multiGet( + List.of("test:multi:1", "test:multi:missing", "test:multi:absent"), STRING_TYPE); + + // assert + assertAll( + () -> assertThat(result).hasSize(3), + () -> assertThat(result.get(0)).isEqualTo("a"), + () -> assertThat(result.get(1)).isNull(), + () -> assertThat(result.get(2)).isNull() + ); + } + + @DisplayName("빈 키 리스트를 전달하면, 빈 리스트가 반환된다.") + @Test + void returnsEmptyList_whenKeysEmpty() { + // act + List result = cacheRepository.multiGet(List.of(), STRING_TYPE); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("여러 키-값을 한 번에 저장할 때,") + @Nested + class MultiPut { + + @DisplayName("저장된 값을 각각 조회할 수 있다.") + @Test + void storesAllEntries() { + // arrange & act + cacheRepository.multiPut( + Map.of("test:mput:1", "x", "test:mput:2", "y", "test:mput:3", "z"), + () -> Duration.ofMinutes(1) + ); + + // assert + assertAll( + () -> assertThat(cacheRepository.get("test:mput:1", STRING_TYPE)).isEqualTo("x"), + () -> assertThat(cacheRepository.get("test:mput:2", STRING_TYPE)).isEqualTo("y"), + () -> assertThat(cacheRepository.get("test:mput:3", STRING_TYPE)).isEqualTo("z") + ); + } + + @DisplayName("TTL이 만료되면, null이 반환된다.") + @Test + void returnsNull_whenTtlExpired() { + // arrange + cacheRepository.multiPut(Map.of("test:mput:ttl", "expiring"), () -> Duration.ofSeconds(1)); + + // act & assert + await().atMost(Duration.ofSeconds(3)) + .pollInterval(Duration.ofMillis(200)) + .untilAsserted(() -> assertThat(cacheRepository.get("test:mput:ttl", STRING_TYPE)).isNull()); + } + + @DisplayName("빈 맵을 전달하면, 예외 없이 통과한다.") + @Test + void doesNothing_whenEntriesEmpty() { + // act & assert + cacheRepository.multiPut(Map.of(), () -> Duration.ofMinutes(1)); + } + } + + record TestItem(Long id, String name) { + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/BaseE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/support/BaseE2ETest.java index 461001e2f..d5747d7d4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/support/BaseE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/support/BaseE2ETest.java @@ -6,6 +6,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; /** * E2E 테스트의 공통 설정을 제공한다. @@ -22,8 +23,12 @@ public abstract class BaseE2ETest { @Autowired protected DatabaseCleanUp databaseCleanUp; + @Autowired + protected RedisCleanUp redisCleanUp; + @AfterEach void cleanUp() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } -} \ No newline at end of file +} diff --git a/k6/product-detail-test.js b/k6/product-detail-test.js new file mode 100644 index 000000000..c790ca969 --- /dev/null +++ b/k6/product-detail-test.js @@ -0,0 +1,66 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +// ─── Config ───────────────────────────────────────────── +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const LOGIN_ID = __ENV.LOGIN_ID || 'loopers'; +const LOGIN_PW = __ENV.LOGIN_PW || 'loopersloopers'; + +// 테스트 대상 상품 ID 범위 (DB에 존재하는 범위에 맞게 조정) +const MIN_PRODUCT_ID = 1; +const MAX_PRODUCT_ID = __ENV.MAX_PRODUCT_ID ? parseInt(__ENV.MAX_PRODUCT_ID) : 1000; + +// ─── Load Stages ──────────────────────────────────────── +// Warm-up(30s) → Ramp-up(1m) → Stress(30s) → Ramp-down(1m), 총 약 3분 +const LOAD_STAGES = [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 0 }, +]; + +// ─── Options ──────────────────────────────────────────── +export const options = { + scenarios: { + productDetail: { + executor: 'ramping-vus', + startVUs: 0, + stages: LOAD_STAGES, + exec: 'productDetail', + }, + }, + thresholds: { + 'http_req_duration': ['p(95)<500', 'p(99)<1000'], + 'http_req_failed': ['rate<0.01'], + }, +}; + +// ─── HTTP Headers ─────────────────────────────────────── +const HEADERS = { + headers: { + 'Content-Type': 'application/json', + 'X-Loopers-LoginId': LOGIN_ID, + 'X-Loopers-LoginPw': LOGIN_PW, + }, +}; + +// ─── Random Generators ────────────────────────────────── + +/** 인기 상품에 트래픽이 집중되는 롱테일 분포 시뮬레이션 */ +function randomProductId() { + const r = Math.random(); + // 80%의 요청이 상위 20% 상품에 집중 + const range = MAX_PRODUCT_ID - MIN_PRODUCT_ID + 1; + if (r < 0.8) { + return MIN_PRODUCT_ID + Math.floor(Math.random() * Math.ceil(range * 0.2)); + } + return MIN_PRODUCT_ID + Math.floor(Math.random() * range); +} + +// ─── Scenario Function ───────────────────────────────── + +export function productDetail() { + const productId = randomProductId(); + const res = http.get(`${BASE_URL}/api/v1/products/${productId}`, HEADERS); + check(res, { 'status is 200': (r) => r.status === 200 }); +} diff --git a/k6/product-list-test.js b/k6/product-list-test.js new file mode 100644 index 000000000..b42420a52 --- /dev/null +++ b/k6/product-list-test.js @@ -0,0 +1,102 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +// ─── Config ───────────────────────────────────────────── +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; +const LOGIN_ID = __ENV.LOGIN_ID || 'loopers'; +const LOGIN_PW = __ENV.LOGIN_PW || 'loopersloopers'; + +// ─── Load Stages ──────────────────────────────────────── +// Warm-up(30s) → Ramp-up(1m) → Stress(30s) → Ramp-down(1m), 총 약 3분 +const LOAD_STAGES = [ + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '1m', target: 0 }, +]; + +// ─── Fixed Parameters ─────────────────────────────────── +const PAGE = 0; +const SIZE = 20; + +// ─── Scenario Weights ─────────────────────────────────── +// 정렬 옵션 × 인증 여부 × 브랜드 필터 조합 +const SCENARIO_WEIGHTS = { + listByLikesAsGuest: 0.20, // 좋아요순 · 비회원 + listByLikesAsMember: 0.15, // 좋아요순 · 회원 + listByLatestAsGuest: 0.15, // 최신순 · 비회원 + listByLatestAsMember: 0.10, // 최신순 · 회원 + listByPriceAsGuest: 0.10, // 가격순 · 비회원 + listByPriceAsMember: 0.05, // 가격순 · 회원 + listByBrandLikesAsGuest: 0.15, // 브랜드+좋아요순 · 비회원 + listByBrandLikesAsMember: 0.10, // 브랜드+좋아요순 · 회원 +}; + +// ─── Options ──────────────────────────────────────────── +const scenarios = {}; +for (const [name, weight] of Object.entries(SCENARIO_WEIGHTS)) { + scenarios[name] = { + executor: 'ramping-vus', + startVUs: 0, + stages: LOAD_STAGES.map((s) => ({ + duration: s.duration, + target: Math.max(Math.ceil(s.target * weight), s.target > 0 ? 1 : 0), + })), + exec: name, + }; +} + +export const options = { + scenarios, + thresholds: { + 'http_req_duration': ['p(95)<500', 'p(99)<1000'], + 'http_req_failed': ['rate<0.01'], + }, +}; + +// ─── HTTP Headers ─────────────────────────────────────── +const GUEST_HEADERS = { + headers: { 'Content-Type': 'application/json' }, +}; + +const MEMBER_HEADERS = { + headers: { + 'Content-Type': 'application/json', + 'X-Loopers-LoginId': LOGIN_ID, + 'X-Loopers-LoginPw': LOGIN_PW, + }, +}; + +// ─── Random Generators ────────────────────────────────── + +/** brandId 1~10 중 랜덤 */ +function randomBrandId() { + return Math.floor(Math.random() * 10) + 1; +} + +// ─── Request Helpers ──────────────────────────────────── + +function fetchProductList(sort, httpParams, brandId) { + let url = `${BASE_URL}/api/v1/products?sort=${sort}&page=${PAGE}&size=${SIZE}`; + if (brandId) url += `&brandId=${brandId}`; + const res = http.get(url, httpParams); + check(res, { 'status is 200': (r) => r.status === 200 }); +} + +// ─── Scenario Functions ───────────────────────────────── + +// 목록 조회 — 좋아요순 +export function listByLikesAsGuest() { fetchProductList('LIKE_COUNT_DESC', GUEST_HEADERS); } +export function listByLikesAsMember() { fetchProductList('LIKE_COUNT_DESC', MEMBER_HEADERS); } + +// 목록 조회 — 최신순 +export function listByLatestAsGuest() { fetchProductList('CREATED_AT_DESC', GUEST_HEADERS); } +export function listByLatestAsMember() { fetchProductList('CREATED_AT_DESC', MEMBER_HEADERS); } + +// 목록 조회 — 가격순 +export function listByPriceAsGuest() { fetchProductList('PRICE_ASC', GUEST_HEADERS); } +export function listByPriceAsMember() { fetchProductList('PRICE_ASC', MEMBER_HEADERS); } + +// 목록 조회 — 브랜드 + 좋아요순 +export function listByBrandLikesAsGuest() { fetchProductList('LIKE_COUNT_DESC', GUEST_HEADERS, randomBrandId()); } +export function listByBrandLikesAsMember() { fetchProductList('LIKE_COUNT_DESC', MEMBER_HEADERS, randomBrandId()); } diff --git a/k6/run.sh b/k6/run.sh new file mode 100755 index 000000000..f7f5696e0 --- /dev/null +++ b/k6/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +SCRIPT="${1:?Usage: ./k6/run.sh [k6 options...]}" +shift + +K6_WEB_DASHBOARD=true \ +K6_WEB_DASHBOARD_EXPORT="k6/${SCRIPT}-report.html" \ +k6 run "k6/${SCRIPT}-test.js" "$@" diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java index 35bf94f06..83edd0458 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -10,9 +10,6 @@ public class RedisTestContainersConfig { static { redisContainer.start(); - } - - public RedisTestContainersConfig() { System.setProperty("datasource.redis.database", "0"); System.setProperty("datasource.redis.master.host", redisContainer.getHost()); System.setProperty("datasource.redis.master.port", String.valueOf(redisContainer.getFirstMappedPort()));