From 4e5812ec49d4a9a6fccba464853c7a9e654eb65f Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Thu, 12 Mar 2026 01:13:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(index):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20QueryDSL=20=EC=A0=84=ED=99=98=20=EB=B0=8F?= =?UTF-8?q?=20brandId=C2=B7=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=B3=84=20=EB=B3=B5=ED=95=A9=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 108 +++++++++++ .../domain/product/ProductService.java | 17 +- .../product/ProductJpaRepository.java | 23 --- .../product/ProductRepositoryImpl.java | 75 +++++++- .../product/ProductSortCondition.java | 43 +++++ docs/performance/index-analysis.md | 143 ++++++++++++++ .../com/loopers/utils/DatabaseCleanUp.java | 9 + scripts/create-product-indexes.sql | 35 ++++ scripts/seed-products.sql | 182 ++++++++++++++++++ 9 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java create mode 100644 docs/performance/index-analysis.md create mode 100644 scripts/create-product-indexes.sql create mode 100644 scripts/seed-products.sql diff --git a/CLAUDE.md b/CLAUDE.md index 68f396a58..e1198f875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,115 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter) - AI는 제안만 가능, 임의 판단 금지 - 중간 결과 보고 및 개발자 개입 허용 +### 의사결정 체크포인트 (필수) +AI는 아래 시점마다 **반드시 멈추고 개발자에게 승인을 요청**한다. 승인 없이 다음 단계로 진행 금지. + +1. **기능 단위 구현 시작 전**: 구현할 내용, 영향 범위, 예상 파일 목록을 보고하고 승인 대기 +2. **설계 방향 결정 시**: 복수 대안이 존재하는 경우 각 대안의 트레이드오프를 제시하고 선택 요청 +3. **기존 코드 수정 시**: 수정 대상 파일과 변경 이유를 명시하고 승인 대기 +4. **테스트 전략 결정 시**: 단위/통합/E2E 중 어떤 테스트를 어떻게 작성할지 제안 후 승인 대기 + +### 대안 제시 원칙 (필수) +구현 방식을 결정해야 하는 모든 시점에서 AI는 **독단적으로 선택하지 않는다**. +반드시 **최소 2개 이상의 대안**을 제시하고 개발자가 선택한 뒤에만 구현을 시작한다. + +#### 대안 제시가 필요한 시점 +- 라이브러리/프레임워크 선택 (예: SQL vs QueryDSL, Redis vs in-memory) +- 구현 패턴 선택 (예: switch vs enum Strategy, 인터페이스 vs 추상 클래스) +- 데이터 저장 방식 선택 (예: SQL 스크립트 vs CommandLineRunner vs TestFixture) +- 캐시/동시성/트랜잭션 전략 선택 +- 테스트 방식 선택 (예: Mockito vs TestContainers, 단위 vs 통합) +- 기타 "어떻게 구현할까"라는 질문이 생기는 모든 순간 + +#### 대안 제시 형식 +``` +구현 방식을 결정해야 합니다. 아래 대안 중 선택해주세요. + +**A: [방법명]** +- 방식: ... +- 장점: ... +- 단점: ... + +**B: [방법명]** +- 방식: ... +- 장점: ... +- 단점: ... + +(필요 시 C도 추가) + +어떤 방식으로 진행할까요? +``` + +#### 금지 행동 +- ❌ 대안 제시 없이 AI가 판단한 "더 나은" 방법을 바로 구현하는 행위 +- ❌ 대안을 설명하면서 동시에 구현을 시작하는 행위 +- ❌ "A 방식으로 진행하겠습니다"처럼 AI가 선택을 결론 짓는 행위 + +### 과정 기록 원칙 (필수) + +모든 설계 결정, 실험 과정, 테스트 결과는 **`.idea/volume-{N}-discussion.md`에 기록**한다. +파일이 없으면 대화 시작 시 생성. 파일이 있으면 기존 내용에 **추가(append)**. + +#### 기록 대상 (반드시 기록) +- 설계 방향 선택, 라이브러리/전략 결정, 트레이드오프 논의 +- 구현 방식 변경 및 변경 이유 +- **테스트 실행 결과** (PASS/FAIL, 수치, EXPLAIN 결과 등) — 결정의 근거로 반드시 첨부 +- 단계적 접근에서 각 단계의 결과와 다음 단계로 넘어간 이유 +- 기각된 대안과 기각 이유 +- **테스트 코드가 이후 삭제/변경되더라도 당시의 실행 기록과 의사결정은 반드시 유지** + +#### 기록 형식 +```markdown +## [날짜] 주제 + +### 상황 +현재 문제 또는 목표 + +### 실험/테스트 기록 +- 시도한 방법: ... +- 실행 결과: (수치, EXPLAIN 출력, 테스트 PASS/FAIL 등) +- 관찰: ... + +### 선택지 비교 +| 항목 | 방법 A | 방법 B | +|------|--------|--------| +| 성능 | ... | ... | +| 복잡도 | ... | ... | + +### 결정 +- 선택: 방법 A +- 이유: (테스트 결과 기반으로 구체적으로) + +### 기각된 대안 +- 방법 B: 기각 이유 (근거 포함) + +### 다음 단계 +- ... +``` + +#### 단계적 구현 원칙 +- 기술 도입은 **가장 단순한 것부터 시작해 점진적으로 고도화**한다 +- 각 단계에서 테스트로 효과를 검증한 뒤 다음 단계로 진행 +- 예: 캐시 → (1) Spring 기본 캐시(in-memory) → (2) TTL 제어 → (3) Redis 분산 캐시 +- 각 단계의 한계를 직접 확인하고 기록한 뒤 다음 단계로 이동 + +### 커밋 단위 원칙 (필수) +- **1 논리 단위 = 1 커밋**: 기능 하나, 리팩토링 하나, 테스트 추가 하나를 각각 별도 커밋 +- 금지: 여러 기능/도메인/레이어 변경을 하나의 커밋에 혼합 +- 커밋 메시지 형식: `type(scope): 설명` (예: `feat(product): 상품 목록 인덱스 추가`) +- 커밋 타이밍: AI가 자의적으로 커밋 실행 금지. 커밋 시점 시 커밋 메세지만 전달. + +**커밋 단위 예시 (올바른 분리)** +``` +feat(product): products 테이블 복합 인덱스 추가 +feat(product): 상품 목록 조회 QueryDSL 필터/정렬 구현 +test(product): 상품 목록 조회 통합 테스트 추가 +feat(cache): 상품 상세 Redis 캐시 적용 +feat(cache): 상품 목록 Redis 캐시 적용 +``` + ### Never Do (절대 금지) +- ❌ 코드에 불필요한 주석 작성 금지 — 로직이 자명하지 않은 경우에만 작성. 클래스/메서드 설명 주석, Phase 설명 주석, 한계 설명 주석 등 코드로 표현 가능한 내용은 모두 금지 - ❌ 실제 동작하지 않는 코드 작성 금지 - ❌ null-safety 위반 금지 (Optional 활용) - ❌ println 코드 남기지 말 것 (`@Slf4j` 사용) 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 22aa5aee2..056c8271b 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 @@ -56,14 +56,17 @@ public void deleteProduct(String productId) { productRepository.save(product); } - public Page getProducts(String brandId, String sortBy, Pageable pageable) { - // brandId가 제공되면 Brand PK로 변환 - Long refBrandId = null; - if (brandId != null && !brandId.isBlank()) { - BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); - refBrandId = brand.getId(); + public Long resolveRefBrandId(String brandId) { + if (brandId == null || brandId.isBlank()) { + return null; } + BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); + return brand.getId(); + } + + public Page getProducts(String brandId, String sortBy, Pageable pageable) { + Long refBrandId = resolveRefBrandId(brandId); return productRepository.findProducts(refBrandId, sortBy, pageable); } 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 3b51269f4..d27c47d5b 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 @@ -2,8 +2,6 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.vo.ProductId; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -18,27 +16,6 @@ public interface ProductJpaRepository extends JpaRepository boolean existsByProductId(ProductId productId); - @Query( - value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY updated_at DESC", - countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", - nativeQuery = true - ) - Page findActiveSortByLatest(@Param("refBrandId") Long refBrandId, Pageable pageable); - - @Query( - value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY price ASC", - countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", - nativeQuery = true - ) - Page findActiveSortByPriceAsc(@Param("refBrandId") Long refBrandId, Pageable pageable); - - @Query( - value = "SELECT * FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId) ORDER BY like_count DESC, updated_at DESC", - countQuery = "SELECT COUNT(*) FROM products WHERE deleted_at IS NULL AND (:refBrandId IS NULL OR ref_brand_id = :refBrandId)", - nativeQuery = true - ) - Page findActiveSortByLikesDesc(@Param("refBrandId") Long refBrandId, Pageable pageable); - @Modifying(clearAutomatically = true) @Query( value = "UPDATE products SET stock_quantity = stock_quantity - :quantity WHERE id = :productId AND stock_quantity >= :quantity", diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 9fe676923..f85bd8046 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,10 +1,19 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.common.cursor.CursorPageResult; +import com.loopers.domain.common.vo.RefBrandId; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.QProductModel; import com.loopers.domain.product.vo.ProductId; +import com.loopers.infrastructure.common.cursor.CursorEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -16,6 +25,8 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + private final CursorEncoder cursorEncoder; @Override public ProductModel save(ProductModel product) { @@ -34,12 +45,66 @@ public boolean existsByProductId(ProductId productId) { @Override public Page findProducts(Long refBrandId, String sortBy, Pageable pageable) { - if ("likes_desc".equals(sortBy)) { - return productJpaRepository.findActiveSortByLikesDesc(refBrandId, pageable); - } else if ("price_asc".equals(sortBy)) { - return productJpaRepository.findActiveSortByPriceAsc(refBrandId, pageable); + QProductModel product = QProductModel.productModel; + + BooleanExpression condition = product.deletedAt.isNull(); + if (refBrandId != null) { + condition = condition.and(product.refBrandId.eq(new RefBrandId(refBrandId))); } - return productJpaRepository.findActiveSortByLatest(refBrandId, pageable); + + ProductSortCondition sortCondition = ProductSortCondition.from(sortBy); + + List content = queryFactory + .selectFrom(product) + .where(condition) + .orderBy(sortCondition.toOrderSpecifiers(product)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .where(condition) + .fetchOne(); + + return new PageImpl<>(content, pageable, total == null ? 0L : total); + } + + @Override + public CursorPageResult findProductsByCursor( + Long refBrandId, String sortBy, String cursor, int size) { + QProductModel product = QProductModel.productModel; + ProductCursorCondition cursorCondition = ProductCursorCondition.from(sortBy); + + BooleanExpression baseCondition = product.deletedAt.isNull(); + if (refBrandId != null) { + baseCondition = baseCondition.and(product.refBrandId.eq(new RefBrandId(refBrandId))); + } + + BooleanExpression keysetCondition = null; + if (cursor != null) { + ProductCursor decoded = cursorEncoder.decode(cursor, ProductCursor.class); + if (!cursorCondition.equals(ProductCursorCondition.fromType(decoded.type()))) { + throw new CoreException(ErrorType.BAD_REQUEST, "커서와 정렬 기준이 일치하지 않습니다."); + } + keysetCondition = cursorCondition.toCursorPredicate(product, decoded); + } + + List content = queryFactory + .selectFrom(product) + .where(baseCondition, keysetCondition) + .orderBy(cursorCondition.toOrderSpecifiers(product)) + .limit(size + 1L) + .fetch(); + + boolean hasNext = content.size() > size; + List items = hasNext ? content.subList(0, size) : content; + String nextCursor = hasNext + ? cursorEncoder.encode(ProductCursor.from(items.get(items.size() - 1), sortBy)) + : null; + + return new CursorPageResult<>(items, nextCursor, hasNext, size); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java new file mode 100644 index 000000000..26bcda321 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.QProductModel; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberPath; + +import java.math.BigDecimal; + +public enum ProductSortCondition { + + LIKES_DESC { + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel product) { + return new OrderSpecifier[] { product.likeCount.desc(), product.updatedAt.desc() }; + } + }, + + PRICE_ASC { + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel product) { + NumberPath pricePath = Expressions.numberPath(BigDecimal.class, product, "price"); + return new OrderSpecifier[] { pricePath.asc() }; + } + }, + + LATEST { + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel product) { + return new OrderSpecifier[] { product.updatedAt.desc() }; + } + }; + + public abstract OrderSpecifier[] toOrderSpecifiers(QProductModel product); + + public static ProductSortCondition from(String sortBy) { + return switch (sortBy) { + case "likes_desc" -> LIKES_DESC; + case "price_asc" -> PRICE_ASC; + default -> LATEST; + }; + } +} diff --git a/docs/performance/index-analysis.md b/docs/performance/index-analysis.md new file mode 100644 index 000000000..179da8ea7 --- /dev/null +++ b/docs/performance/index-analysis.md @@ -0,0 +1,143 @@ +# 상품 목록 조회 인덱스 성능 분석 + +- 분석 대상 테이블: `products` +- 데이터 규모: 100,000건 (brands 10개, 각 10,000건) +- MySQL 버전: 8.x +- 분석 일자: 2026-03-07 + +--- + +## AS-IS — 인덱스 적용 전 + +### 분석 쿼리 + +```sql +-- Q1: 브랜드 필터 + 좋아요 순 정렬 (likes_desc) +SELECT * FROM products +WHERE deleted_at IS NULL AND ref_brand_id = 1 +ORDER BY like_count DESC, updated_at DESC +LIMIT 10; + +-- Q2: 브랜드 필터 + 최신순 정렬 (latest) +SELECT * FROM products +WHERE deleted_at IS NULL AND ref_brand_id = 1 +ORDER BY updated_at DESC +LIMIT 10; + +-- Q3: 브랜드 필터 + 가격 오름차순 정렬 (price_asc) +SELECT * FROM products +WHERE deleted_at IS NULL AND ref_brand_id = 1 +ORDER BY price ASC +LIMIT 10; +``` + +### EXPLAIN 결과 + +| 쿼리 | type | key | rows (예측) | Extra | +|------|------|-----|-------------|-------| +| Q1: likes_desc | ALL | NULL | 99,510 | Using where; Using filesort | +| Q2: latest | ALL | NULL | 99,510 | Using where; Using filesort | +| Q3: price_asc | ALL | NULL | 99,510 | Using where; Using filesort | + +### 실행 계획 상세 (MySQL Visual Explain) + +**Q1: likes_desc** +``` +SEQ_SCAN table: products → rows: 100,000 cost: 35.8 +FILTER (ref_brand_id = 1) AND (deleted_at IS NULL) → rows: 10,000 cost: 39.1 +SORT like_count DESC, updated_at DESC → rows: 99,510 cost: 40.3 +LIMIT 10 → total cost: 40.3 +``` + +**Q2: latest** +``` +SEQ_SCAN table: products → rows: 100,000 cost: 36.6 +FILTER (ref_brand_id = 1) AND (deleted_at IS NULL) → rows: 10,000 cost: 39.9 +SORT updated_at DESC → rows: 99,510 cost: 41.0 +LIMIT 10 → total cost: 41.0 +``` + +**Q3: price_asc** +``` +SEQ_SCAN table: products → rows: 100,000 cost: 37.2 +FILTER (ref_brand_id = 1) AND (deleted_at IS NULL) → rows: 10,000 cost: 40.8 +SORT price ASC → rows: 99,510 cost: 42.3 +LIMIT 10 → total cost: 42.3 +``` + +### 문제점 분석 + +**type: ALL (Full Table Scan)** +- 인덱스를 전혀 사용하지 않고 100,000건 전체를 스캔 +- 브랜드 필터(`ref_brand_id = 1`)가 WHERE에 있지만 인덱스가 없어 필터링이 스캔 후 적용됨 + +**key: NULL** +- 사용된 인덱스 없음 + +**Using filesort** +- 정렬 컬럼(`like_count`, `updated_at`, `price`)에 인덱스가 없어 **별도 정렬 연산** 발생 +- 데이터가 늘어날수록 정렬 비용이 선형 증가 + +**병목 구조 요약** +``` +전체 100,000건 스캔 → WHERE 필터(10,000건 추출) → 추출된 행 전체 정렬 → LIMIT 10 +``` +LIMIT 10이 있어도 정렬은 10,000건 전체에 수행됨. 인덱스가 있으면 정렬 없이 순서대로 읽고 멈출 수 있음. + +--- + +## TO-BE — 인덱스 적용 후 + +- 분석 일자: 2026-03-07 +- 적용 방식: `ProductModel @Table(indexes)` 어노테이션 (ddl-auto:create 자동 생성) + +### 적용 인덱스 + +```sql +-- Q1 (likes_desc) 대응 +idx_products_brand_like : (ref_brand_id, like_count DESC, deleted_at) + +-- Q2 (latest) 대응 +idx_products_brand_latest : (ref_brand_id, updated_at DESC, deleted_at) + +-- Q3 (price_asc) 대응 +idx_products_brand_price : (ref_brand_id, price, deleted_at) +``` + +### EXPLAIN 결과 + +| 쿼리 | type | key | rows (예측) | Extra | +|------|------|-----|-------------|-------| +| Q1: likes_desc | ref | idx_products_brand_like | 18,962 | Using index condition; Using filesort | +| Q2: latest | ref | idx_products_brand_latest | 18,962 | Using index condition | +| Q3: price_asc | ref | idx_products_brand_price | 18,962 | Using index condition | + +### AS-IS vs TO-BE 비교 + +| 항목 | AS-IS | TO-BE | +|------|-------|-------| +| type | ALL (Full Table Scan) | ref (Index Scan) | +| key | NULL (인덱스 미사용) | 각 쿼리별 인덱스 사용 | +| rows 예측 | 99,510 | 18,962 (약 81% 감소) | +| Q2/Q3 Extra | Using where; Using filesort | Using index condition | +| Q1 Extra | Using where; Using filesort | Using index condition; **Using filesort 유지** | + +### 분석 + +**개선된 점** +- `type: ALL → ref`: 인덱스를 통해 `ref_brand_id` 조건으로 대상 범위를 한정 +- Q2(latest), Q3(price_asc): `Using filesort` 완전 제거 — 인덱스 순서로 읽어 정렬 연산 생략 +- rows 99,510 → 18,962: 전체 스캔 제거, 브랜드 필터 효과로 스캔 범위 감소 + +**Q1 `Using filesort` 유지 원인** +``` +인덱스: (ref_brand_id, like_count DESC, deleted_at) +쿼리: ORDER BY like_count DESC, updated_at DESC +``` +`updated_at DESC`가 인덱스에 없어 보조 정렬에서 filesort 발생. +완전 제거하려면 인덱스를 `(ref_brand_id, like_count DESC, updated_at DESC, deleted_at)`로 변경 필요. +단, likes_desc 쿼리에서 like_count 동률 상품이 실제로 많지 않으면 보조 정렬 빈도가 낮아 실용적 영향 제한적. + +**rows 예측 18,962 (기대값 10,000보다 높음)** +MySQL 옵티마이저 통계 추정치. `deleted_at IS NULL` 선택도를 통계에 완전히 반영하지 못한 결과. +실제 스캔 행 수는 인덱스 조건으로 제한되므로 Full Table Scan 대비 성능 향상은 유효. diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 85c43ace9..ad17e107e 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -5,6 +5,8 @@ import jakarta.persistence.PersistenceContext; import jakarta.persistence.Table; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,9 @@ public class DatabaseCleanUp implements InitializingBean { @PersistenceContext private EntityManager entityManager; + @Autowired(required = false) + private CacheManager cacheManager; + private final List tableNames = new ArrayList<>(); @Override @@ -37,5 +42,9 @@ public void truncateAllTables() { } entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + + if (cacheManager != null) { + cacheManager.getCacheNames().forEach(name -> cacheManager.getCache(name).clear()); + } } } diff --git a/scripts/create-product-indexes.sql b/scripts/create-product-indexes.sql new file mode 100644 index 000000000..94fd19baa --- /dev/null +++ b/scripts/create-product-indexes.sql @@ -0,0 +1,35 @@ +-- ============================================================================= +-- products 테이블 복합 인덱스 생성 스크립트 +-- +-- 목적: 상품 목록 조회 3개 쿼리 패턴에 대한 인덱스 적용 +-- Q1 (likes_desc): ref_brand_id 필터 + like_count DESC 정렬 + updated_at 보조 정렬 +-- Q2 (latest) : ref_brand_id 필터 + updated_at DESC 정렬 +-- Q3 (price_asc) : ref_brand_id 필터 + price ASC 정렬 +-- +-- 적용 방식: +-- 로컬: ProductModel @Table(indexes) 어노테이션 → ddl-auto:create 시 자동 생성 +-- 운영: 이 스크립트로 수동 적용 (또는 Flyway 도입 시 마이그레이션으로 전환) +-- +-- 실행 방법: +-- mysql -u application -p application loopers < scripts/create-product-indexes.sql +-- ============================================================================= + +-- Q1: likes_desc 대응 +-- ref_brand_id 로 브랜드 필터 → like_count DESC 로 정렬 인덱스 → deleted_at IS NULL 필터 포함 +CREATE INDEX idx_products_brand_like + ON products (ref_brand_id, like_count DESC, deleted_at); + +-- Q2: latest 대응 +-- ref_brand_id 로 브랜드 필터 → updated_at DESC 로 정렬 인덱스 → deleted_at IS NULL 필터 포함 +CREATE INDEX idx_products_brand_latest + ON products (ref_brand_id, updated_at DESC, deleted_at); + +-- Q3: price_asc 대응 +-- ref_brand_id 로 브랜드 필터 → price ASC 로 정렬 인덱스 → deleted_at IS NULL 필터 포함 +CREATE INDEX idx_products_brand_price + ON products (ref_brand_id, price, deleted_at); + +-- --------------------------------------------------------------------------- +-- 인덱스 생성 확인 +-- --------------------------------------------------------------------------- +SHOW INDEX FROM products; diff --git a/scripts/seed-products.sql b/scripts/seed-products.sql new file mode 100644 index 000000000..b23f28251 --- /dev/null +++ b/scripts/seed-products.sql @@ -0,0 +1,182 @@ +-- ============================================================================= +-- Volume 5 성능 테스트용 시드 데이터 (brands 10개 + products 약 1천280만건) +-- +-- 실행 조건: +-- - 애플리케이션을 local 프로파일로 기동 완료 후 실행 (ddl-auto: create로 테이블 생성 완료) +-- - 이미 데이터가 있으면 brands의 UNIQUE 제약으로 중복 삽입 차단됨 +-- - products 데이터가 없는 상태(truncate 이후)에서 실행할 것 +-- +-- 실행 방법: +-- mysql -u application -p application loopers < scripts/seed-products.sql +-- 또는 MySQL Workbench / IntelliJ Database 에서 직접 실행 +-- +-- 예상 실행 시간: Step 3까지 약 30초, Step 4 배가 7라운드 약 5~15분 +-- 최종 건수: 10만 × 2^7 = 12,800,000건 (약 1천280만) +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- Step 1. 브랜드 10개 삽입 +-- brand_id: BRD01 ~ BRD10 (VARCHAR(10) 제약) +-- brand_name: 다양한 브랜드명 +-- --------------------------------------------------------------------------- +INSERT INTO brands (brand_id, brand_name, created_at, updated_at, deleted_at) +VALUES + ('BRD01', 'Alpha Collection', NOW(), NOW(), NULL), + ('BRD02', 'Beta Mode', NOW(), NOW(), NULL), + ('BRD03', 'Gamma Style', NOW(), NOW(), NULL), + ('BRD04', 'Delta Wear', NOW(), NOW(), NULL), + ('BRD05', 'Epsilon Labs', NOW(), NOW(), NULL), + ('BRD06', 'Zeta Boutique', NOW(), NOW(), NULL), + ('BRD07', 'Eta Edition', NOW(), NOW(), NULL), + ('BRD08', 'Theta Co', NOW(), NOW(), NULL), + ('BRD09', 'Iota Works', NOW(), NOW(), NULL), + ('BRD10', 'Kappa Brand', NOW(), NOW(), NULL) +ON DUPLICATE KEY UPDATE brand_name = VALUES(brand_name); + +-- --------------------------------------------------------------------------- +-- Step 2. 숫자 생성용 임시 테이블 (0 ~ 99999, 총 10만 행) +-- TEMPORARY TABLE을 CROSS JOIN에서 재참조하면 [HY000][1137] 발생. +-- → 각 자릿수를 인라인 서브쿼리(UNION ALL)로 대체하여 해결. +-- --------------------------------------------------------------------------- +DROP TEMPORARY TABLE IF EXISTS tmp_numbers; +CREATE TEMPORARY TABLE tmp_numbers (n INT UNSIGNED NOT NULL PRIMARY KEY); + +INSERT INTO tmp_numbers (n) +SELECT (a.d + b.d * 10 + c.d * 100 + d.d * 1000 + e.d * 10000) AS n +FROM + (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a +CROSS JOIN + (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) b +CROSS JOIN + (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) c +CROSS JOIN + (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d +CROSS JOIN + (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 + UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) e; + +-- --------------------------------------------------------------------------- +-- Step 3. 상품 100,000건 삽입 +-- +-- [컬럼별 분포 전략] +-- +-- product_id : 'P' + 8자리 zero-padding (P00000001 ~ P00100000) +-- VARCHAR(20) 제약 만족 +-- +-- ref_brand_id : n % 10 → 브랜드 10개에 균등 분포 (각 10,000건) +-- +-- product_name : '{브랜드명} Item-{5자리번호}' +-- 브랜드별 상품명이 구분되어 필터 테스트에 유리 +-- +-- price : ((n % 50) + 1) * 10000 → 10,000원 ~ 500,000원 (10,000원 단위 50단계) +-- 인덱스 정렬 테스트를 위해 넓은 범위 + 고른 분포 +-- +-- stock_quantity : n % 501 → 0 ~ 500 (균등 분포) +-- +-- like_count : 멱법칙(Power-law) 분포 — 실제 서비스와 유사한 롱테일 패턴 +-- - n % 100 < 60 → 0 ~ 49 (60%: 대부분 낮은 좋아요) +-- - n % 100 < 85 → 50 ~ 999 (25%: 중간 좋아요) +-- - n % 100 < 97 → 1000 ~4999(12%: 인기 상품) +-- - n % 100 >= 97 → 5000 ~9999(3%: 최상위 인기) +-- → likes_desc 정렬 시 인덱스 효과가 극명하게 드러나도록 설계 +-- +-- updated_at : 최근 365일 내 분산 → 최신순 정렬 인덱스 검증 +-- created_at : 최근 730일 내 분산 (updated_at보다 항상 이전) +-- --------------------------------------------------------------------------- +INSERT INTO products + (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT + CONCAT('P', LPAD(t.n + 1, 8, '0')) AS product_id, + + b.id AS ref_brand_id, + + CONCAT(b.brand_name, ' Item-', LPAD(FLOOR(t.n / 10) + 1, 5, '0')) + AS product_name, + + ((t.n % 50) + 1) * 10000 AS price, + + t.n % 501 AS stock_quantity, + + CASE + WHEN t.n % 100 < 60 THEN (t.n * 17) % 50 + WHEN t.n % 100 < 85 THEN (t.n * 13) % 950 + 50 + WHEN t.n % 100 < 97 THEN (t.n * 11) % 4000 + 1000 + ELSE (t.n * 7) % 5000 + 5000 + END AS like_count, + + NOW() - INTERVAL (t.n % 730) DAY AS created_at, + NOW() - INTERVAL (t.n % 365) DAY AS updated_at, + + NULL AS deleted_at + +FROM tmp_numbers t +JOIN brands b + ON b.brand_id = CONCAT('BRD', LPAD((t.n % 10) + 1, 2, '0')) +WHERE t.n < 100000; + +DROP TEMPORARY TABLE IF EXISTS tmp_numbers; + +-- --------------------------------------------------------------------------- +-- Step 4. 배가(doubling) 방식으로 10만 → 약 1천280만건으로 확장 +-- +-- 매 라운드마다 현재 테이블 전체를 복사하되 product_id 앞에 라운드 문자(A~G)를 추가. +-- 7라운드 후: 10만 × 2^7 = 12,800,000건 (약 1천280만) +-- product_id 최대 길이: 라운드 수(7) + 시드 길이(9) = 16자 ≤ VARCHAR(20) 제약 충족 +-- --------------------------------------------------------------------------- +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('A', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('B', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('C', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('D', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('E', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('F', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +INSERT INTO products (product_id, ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at) +SELECT CONCAT('G', product_id), ref_brand_id, product_name, price, stock_quantity, like_count, created_at, updated_at, deleted_at +FROM products; + +-- --------------------------------------------------------------------------- +-- Step 5. 삽입 결과 확인 +-- --------------------------------------------------------------------------- +SELECT '=== 삽입 결과 확인 ===' AS info; + +SELECT COUNT(*) AS total_products FROM products; + +SELECT + b.brand_name, + COUNT(p.id) AS product_count, + MIN(p.like_count) AS min_likes, + MAX(p.like_count) AS max_likes, + ROUND(AVG(p.like_count), 1) AS avg_likes, + MIN(p.price) AS min_price, + MAX(p.price) AS max_price +FROM products p +JOIN brands b ON b.id = p.ref_brand_id +GROUP BY b.id, b.brand_name +ORDER BY b.brand_name; + +SELECT + '0~49' AS like_range, COUNT(*) AS cnt FROM products WHERE like_count < 50 +UNION ALL SELECT '50~999', COUNT(*) FROM products WHERE like_count BETWEEN 50 AND 999 +UNION ALL SELECT '1000~4999', COUNT(*) FROM products WHERE like_count BETWEEN 1000 AND 4999 +UNION ALL SELECT '5000~9999', COUNT(*) FROM products WHERE like_count >= 5000; \ No newline at end of file From 007482d6640368a75593066fe976455ef47332d3 Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Thu, 12 Mar 2026 01:13:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(structure):=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=88=98=20=EB=B9=84=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=C2=B7=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../com/loopers/application/like/LikeApp.java | 10 + .../com/loopers/domain/like/LikeService.java | 3 + .../loopers/domain/product/ProductModel.java | 12 +- .../like/LikeCountSyncIntegrationTest.java | 143 ++++ .../like/SortStrategyComparisonTest.java | 611 ++++++++++++++++++ docs/performance/sort-strategy-analysis.md | 111 ++++ 6 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java create mode 100644 docs/performance/sort-strategy-analysis.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java index 97859e86b..629f3fc62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java @@ -5,6 +5,8 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.LikeService; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -17,12 +19,20 @@ public class LikeApp { private final LikeService likeService; private final LikeRepository likeRepository; + @Caching(evict = { + @CacheEvict(value = "product", key = "#productId"), + @CacheEvict(value = "products", allEntries = true) + }) @Transactional public LikeInfo addLike(Long memberId, String productId) { LikeModel like = likeService.addLike(memberId, productId); return LikeInfo.from(like); } + @Caching(evict = { + @CacheEvict(value = "product", key = "#productId"), + @CacheEvict(value = "products", allEntries = true) + }) @Transactional public void removeLike(Long memberId, String productId) { likeService.removeLike(memberId, productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index c72eeedc7..8a7a8ec9d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -9,6 +9,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -17,6 +18,7 @@ public class LikeService { private final LikeRepository likeRepository; private final ProductRepository productRepository; + @Transactional public LikeModel addLike(Long memberId, String productId) { // 상품 존재 확인 ProductModel product = productRepository.findByProductId(new ProductId(productId)) @@ -45,6 +47,7 @@ public LikeModel addLike(Long memberId, String productId) { }); } + @Transactional public void removeLike(Long memberId, String productId) { // 상품 존재 확인 ProductModel product = productRepository.findByProductId(new ProductId(productId)) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 522bfca18..3340f6ab5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -19,7 +19,17 @@ import java.math.BigDecimal; @Entity -@Table(name = "products") +@Table( + name = "products", + indexes = { + // Q1 likes_desc: 브랜드 필터 + 좋아요 내림차순 + 최신순 보조 정렬 + @Index(name = "idx_products_brand_like", columnList = "ref_brand_id, like_count DESC, deleted_at"), + // Q2 latest: 브랜드 필터 + 최신순 + @Index(name = "idx_products_brand_latest", columnList = "ref_brand_id, updated_at DESC, deleted_at"), + // Q3 price_asc: 브랜드 필터 + 가격 오름차순 + @Index(name = "idx_products_brand_price", columnList = "ref_brand_id, price, deleted_at") + } +) @Getter public class ProductModel extends BaseEntity { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java new file mode 100644 index 000000000..1565654b9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("like_count 동기화 정합성 통합 테스트") +class LikeCountSyncIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ProductModel freshProduct(ProductModel product) { + return productRepository.findById(product.getId()).orElseThrow(); + } + + @Test + @DisplayName("addLike 호출 시 like_count가 1 증가한다") + void addLike_incrementsLikeCount() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + assertThat(freshProduct(product).getLikeCount()).isEqualTo(0); + + // when + likeService.addLike(1L, "prod1"); + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("이미 active 상태인 좋아요를 중복 추가해도 like_count는 변하지 않는다") + void addLike_duplicate_doesNotChangeLikeCount() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + likeService.addLike(1L, "prod1"); + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + + // when + likeService.addLike(1L, "prod1"); // 중복 추가 + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("removeLike 호출 시 like_count가 1 감소한다") + void removeLike_decrementsLikeCount() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + likeService.addLike(1L, "prod1"); + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + + // when + likeService.removeLike(1L, "prod1"); + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("이미 취소된 좋아요를 중복 취소해도 like_count는 0 아래로 내려가지 않는다") + void removeLike_duplicate_doesNotGoBelowZero() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + likeService.addLike(1L, "prod1"); + likeService.removeLike(1L, "prod1"); + assertThat(freshProduct(product).getLikeCount()).isEqualTo(0); + + // when + likeService.removeLike(1L, "prod1"); // 중복 취소 + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("soft-delete된 좋아요를 재추가(복원)하면 like_count가 1 증가한다") + void addLike_restore_incrementsLikeCount() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + likeService.addLike(1L, "prod1"); // like_count = 1 + likeService.removeLike(1L, "prod1"); // like_count = 0 (soft-delete) + assertThat(freshProduct(product).getLikeCount()).isEqualTo(0); + + // when + likeService.addLike(1L, "prod1"); // soft-delete된 레코드 복원 + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("add → remove → add 시퀀스 후 like_count는 1이다") + void addRemoveAdd_sequence_finalLikeCountIsOne() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + ProductModel product = productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + // when + likeService.addLike(1L, "prod1"); // like_count = 1 + likeService.removeLike(1L, "prod1"); // like_count = 0 + likeService.addLike(1L, "prod1"); // like_count = 1 (복원) + + // then + assertThat(freshProduct(product).getLikeCount()).isEqualTo(1); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java new file mode 100644 index 000000000..1f24eb7a6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java @@ -0,0 +1,611 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 정렬 구조 비교 실험: 비정규화(like_count) vs 다차원 MaterializedView + * + *

비교 관점: + *

    + *
  • MV 갱신 전략 3종 (Sync / Async / Batch) — 정합성 지연 및 동시성 정확도
  • + *
  • 다차원 쿼리 — 비정규화가 답할 수 없는 브랜드별 랭킹, 시간 윈도우 트렌딩
  • + *
  • 비정규화 vs 잘 설계된 MV — 단순 집계 성능, 다차원 집계, 복구 비교
  • + *
  • 동시성 — 비정규화 원자 UPDATE vs MV Sync 동시 갱신 정확도
  • + *
+ * + *

product_like_stats 테이블 설계: + *

+ *   (product_id, brand_id, time_window, like_count, refreshed_at)
+ *   PK: (product_id, time_window)
+ *   time_window: 'all' | '7d' | '1d'
+ * 
+ * 단일 테이블에 여러 집계 축을 저장함으로써 brand_id + time_window 조합 쿼리를 + * likes 테이블 재스캔 없이 인덱스만으로 처리 가능. + */ +@SpringBootTest +@DisplayName("정렬 구조 비교 실험: 비정규화(like_count) vs 다차원 MaterializedView") +class SortStrategyComparisonTest { + + private static final Logger log = LoggerFactory.getLogger(SortStrategyComparisonTest.class); + + @Autowired + private LikeService likeService; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate.execute( + "CREATE TABLE IF NOT EXISTS product_like_stats (" + + " product_id BIGINT NOT NULL," + + " brand_id BIGINT NOT NULL," + + " time_window VARCHAR(10) NOT NULL," + + " like_count BIGINT NOT NULL DEFAULT 0," + + " refreshed_at DATETIME(6) NOT NULL," + + " PRIMARY KEY (product_id, time_window)" + + ")" + ); + brandService.createBrand("nike", "Nike"); + brandService.createBrand("adidas", "Adidas"); + } + + @AfterEach + void tearDown() { + jdbcTemplate.execute("DROP TABLE IF EXISTS product_like_stats"); + databaseCleanUp.truncateAllTables(); + } + + // ============================================================ + // 헬퍼 + // ============================================================ + + private Long getProductDbId(String productId) { + return jdbcTemplate.queryForObject( + "SELECT id FROM products WHERE product_id = ?", Long.class, productId + ); + } + + private Long getBrandDbId(String productId) { + return jdbcTemplate.queryForObject( + "SELECT ref_brand_id FROM products WHERE product_id = ?", Long.class, productId + ); + } + + private long getStatsCount(Long productDbId, String timeWindow) { + List result = jdbcTemplate.queryForList( + "SELECT like_count FROM product_like_stats WHERE product_id = ? AND time_window = ?", + Long.class, productDbId, timeWindow + ); + return result.isEmpty() ? 0L : result.get(0); + } + + private long aggregateLikeCount(Long productDbId) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM likes WHERE ref_product_id = ? AND deleted_at IS NULL", + Long.class, productDbId + ); + return count == null ? 0L : count; + } + + /** like INSERT만 수행. stats 미갱신 — Batch/Async 전략 시뮬레이션용 */ + private void insertLikeOnly(Long memberId, Long productRefId) { + jdbcTemplate.update( + "INSERT INTO likes (ref_member_id, ref_product_id, created_at, updated_at)" + + " VALUES (?, ?, NOW(6), NOW(6))", + memberId, productRefId + ); + } + + /** created_at을 10일 전으로 설정 — 7일 윈도우 밖의 오래된 좋아요 시뮬레이션 */ + private void insertOldLike(Long memberId, Long productRefId) { + jdbcTemplate.update( + "INSERT INTO likes (ref_member_id, ref_product_id, created_at, updated_at)" + + " VALUES (?, ?, NOW(6) - INTERVAL 10 DAY, NOW(6))", + memberId, productRefId + ); + } + + /** + * Sync 전략: like INSERT와 stats UPSERT를 동일 트랜잭션으로 묶음. + * ON DUPLICATE KEY UPDATE로 stats의 like_count를 원자 증가. + */ + private void insertLikeSync(Long memberId, Long productRefId, Long brandRefId) { + transactionTemplate.execute(status -> { + jdbcTemplate.update( + "INSERT INTO likes (ref_member_id, ref_product_id, created_at, updated_at)" + + " VALUES (?, ?, NOW(6), NOW(6))", + memberId, productRefId + ); + jdbcTemplate.update( + "INSERT INTO product_like_stats (product_id, brand_id, time_window, like_count, refreshed_at)" + + " VALUES (?, ?, 'all', 1, NOW(6))" + + " ON DUPLICATE KEY UPDATE like_count = like_count + 1, refreshed_at = NOW(6)", + productRefId, brandRefId + ); + return null; + }); + } + + /** Batch 전략: 전체 윈도우 일괄 재집계 */ + private void batchRefreshAll() { + jdbcTemplate.update( + "INSERT INTO product_like_stats (product_id, brand_id, time_window, like_count, refreshed_at)" + + " SELECT l.ref_product_id, p.ref_brand_id, 'all', COUNT(*), NOW(6)" + + " FROM likes l JOIN products p ON l.ref_product_id = p.id" + + " WHERE l.deleted_at IS NULL" + + " GROUP BY l.ref_product_id, p.ref_brand_id" + + " ON DUPLICATE KEY UPDATE like_count = VALUES(like_count), refreshed_at = NOW(6)" + ); + } + + /** Batch 전략: 7일 윈도우 재집계 */ + private void batchRefresh7d() { + jdbcTemplate.update( + "INSERT INTO product_like_stats (product_id, brand_id, time_window, like_count, refreshed_at)" + + " SELECT l.ref_product_id, p.ref_brand_id, '7d', COUNT(*), NOW(6)" + + " FROM likes l JOIN products p ON l.ref_product_id = p.id" + + " WHERE l.deleted_at IS NULL AND l.created_at >= NOW(6) - INTERVAL 7 DAY" + + " GROUP BY l.ref_product_id, p.ref_brand_id" + + " ON DUPLICATE KEY UPDATE like_count = VALUES(like_count), refreshed_at = NOW(6)" + ); + } + + // ============================================================ + // Section 1: MV 갱신 전략 3종 비교 + // ============================================================ + + @Test + @DisplayName("[Sync 전략] like INSERT와 stats UPSERT 동일 트랜잭션 → 즉시 정합성") + void mvSync_sameTransaction_immediateConsistency() { + productService.createProduct("prodA", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long nikeDbId = getBrandDbId("prodA"); + + insertLikeSync(1L, prodADbId, nikeDbId); + insertLikeSync(2L, prodADbId, nikeDbId); + + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(2); + assertThat(aggregateLikeCount(prodADbId)).isEqualTo(2); + log.info("[Sync] stats.all={} / likes.count={}", getStatsCount(prodADbId, "all"), aggregateLikeCount(prodADbId)); + } + + @Test + @DisplayName("[Async 전략] like INSERT 후 stats 갱신 전 — 불일치 구간 존재 → 갱신 후 동기화") + void mvAsync_inconsistencyWindow_thenEventuallyConsistent() { + productService.createProduct("prodA", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long prodADbId = getProductDbId("prodA"); + + // Step 1: like만 INSERT (async consumer 아직 미실행) + insertLikeOnly(1L, prodADbId); + insertLikeOnly(2L, prodADbId); + + // 불일치 구간: likes=2, stats=0 + assertThat(aggregateLikeCount(prodADbId)).isEqualTo(2); + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(0); + log.info("[Async — 불일치 구간] likes={}, stats.all={}", aggregateLikeCount(prodADbId), getStatsCount(prodADbId, "all")); + + // Step 2: 비동기 consumer 실행 (별도 트랜잭션) + batchRefreshAll(); + + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(2); + log.info("[Async — 동기화 후] likes={}, stats.all={}", aggregateLikeCount(prodADbId), getStatsCount(prodADbId, "all")); + } + + @Test + @DisplayName("[Batch 전략] 다수 좋아요 후 배치 실행 — stale 구간 확인, 배치 후 일괄 갱신") + void mvBatch_accumulatedLikes_staleUntilBatch() { + productService.createProduct("prodA", "nike", "Nike Air A", new BigDecimal("100000"), 10); + productService.createProduct("prodB", "nike", "Nike Air B", new BigDecimal("100000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long prodBDbId = getProductDbId("prodB"); + + for (int i = 0; i < 5; i++) insertLikeOnly((long) i + 1, prodADbId); + for (int i = 0; i < 2; i++) insertLikeOnly((long) i + 10, prodBDbId); + + // 배치 전: stats 없음 + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(0); + assertThat(getStatsCount(prodBDbId, "all")).isEqualTo(0); + + batchRefreshAll(); + + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(5); + assertThat(getStatsCount(prodBDbId, "all")).isEqualTo(2); + log.info("[Batch] prodA.all={}, prodB.all={}", getStatsCount(prodADbId, "all"), getStatsCount(prodBDbId, "all")); + } + + @Test + @DisplayName("[전략 비교] Sync는 즉시 정확, Batch는 stale → 배치 후 정확") + void strategy_syncVsBatch_consistencyDifference() throws InterruptedException { + productService.createProduct("prodSync", "nike", "Nike Sync", new BigDecimal("100000"), 10); + productService.createProduct("prodBatch", "nike", "Nike Batch", new BigDecimal("100000"), 10); + Long syncDbId = getProductDbId("prodSync"); + Long batchDbId = getProductDbId("prodBatch"); + Long nikeDbId = getBrandDbId("prodSync"); + + int threadCount = 20; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1L; + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + insertLikeSync(memberId, syncDbId, nikeDbId); + insertLikeOnly(memberId + 100L, batchDbId); + } catch (Exception ignored) { + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + done.await(); + executor.shutdown(); + + // Sync: 즉시 정확 + assertThat(getStatsCount(syncDbId, "all")).isEqualTo(threadCount); + // Batch: 배치 전 stale + assertThat(getStatsCount(batchDbId, "all")).isEqualTo(0); + // Batch 실행 후 정확 + batchRefreshAll(); + assertThat(getStatsCount(batchDbId, "all")).isEqualTo(threadCount); + + log.info("[전략 비교] Sync(즉시)={} / Batch(stale→갱신 후)={}", + getStatsCount(syncDbId, "all"), getStatsCount(batchDbId, "all")); + } + + // ============================================================ + // Section 2: 다차원 쿼리 — 비정규화가 답할 수 없는 것 + // ============================================================ + + @Test + @DisplayName("[다차원 — 브랜드별 랭킹] 브랜드 내 인기 순위를 stats 단일 쿼리로 — products 스캔 없음") + void multidimensional_brandRanking_noProductsScan() { + productService.createProduct("prodA", "nike", "Nike Air A", new BigDecimal("100000"), 10); + productService.createProduct("prodB", "nike", "Nike Air B", new BigDecimal("100000"), 10); + productService.createProduct("prodC", "adidas", "Adidas Run C", new BigDecimal("90000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long prodBDbId = getProductDbId("prodB"); + Long prodCDbId = getProductDbId("prodC"); + Long nikeDbId = getBrandDbId("prodA"); + Long adidasDbId = getBrandDbId("prodC"); + + // nike: prodA=3, prodB=1 / adidas: prodC=5 + for (int i = 0; i < 3; i++) insertLikeSync((long) i + 1, prodADbId, nikeDbId); + insertLikeSync(10L, prodBDbId, nikeDbId); + for (int i = 0; i < 5; i++) insertLikeSync((long) i + 20, prodCDbId, adidasDbId); + + // nike 브랜드 내 인기 순위: stats 테이블 단독 쿼리 (products JOIN 불필요) + List nikeRanking = jdbcTemplate.queryForList( + "SELECT product_id FROM product_like_stats" + + " WHERE brand_id = ? AND time_window = 'all'" + + " ORDER BY like_count DESC", + Long.class, nikeDbId + ); + + assertThat(nikeRanking).hasSize(2); + assertThat(nikeRanking.get(0)).isEqualTo(prodADbId); // 3 likes + assertThat(nikeRanking.get(1)).isEqualTo(prodBDbId); // 1 like + // adidas prodC는 nike 랭킹에 포함되지 않음 + assertThat(nikeRanking).doesNotContain(prodCDbId); + log.info("[브랜드 랭킹] nike 순위: {}위={}, {}위={}", 1, nikeRanking.get(0), 2, nikeRanking.get(1)); + } + + @Test + @DisplayName("[다차원 — 시간 윈도우] 전체 랭킹 vs 7일 트렌딩 — 오래된 인기 상품과 최신 급상승 상품 순위 역전") + void multidimensional_timeWindow_rankingReverts() { + productService.createProduct("prodA", "nike", "Nike Air A", new BigDecimal("100000"), 10); + productService.createProduct("prodB", "nike", "Nike Air B", new BigDecimal("100000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long prodBDbId = getProductDbId("prodB"); + Long nikeDbId = getBrandDbId("prodA"); + + // prodA: 오래된 좋아요 5개 (7일 윈도우 밖) → 전체 순위는 높지만 트렌딩은 낮음 + for (int i = 0; i < 5; i++) insertOldLike((long) i + 1, prodADbId); + // prodB: 최근 좋아요 3개 (7일 이내) → 전체 순위는 낮지만 트렌딩은 높음 + for (int i = 0; i < 3; i++) insertLikeOnly((long) i + 10, prodBDbId); + + batchRefreshAll(); + batchRefresh7d(); + + // 전체 랭킹: prodA(5) > prodB(3) + List allRanking = jdbcTemplate.queryForList( + "SELECT product_id FROM product_like_stats" + + " WHERE brand_id = ? AND time_window = 'all' ORDER BY like_count DESC", + Long.class, nikeDbId + ); + assertThat(allRanking.get(0)).isEqualTo(prodADbId); + + // 7일 트렌딩: prodB(3) > prodA(0) — 순위 역전 + List trending7d = jdbcTemplate.queryForList( + "SELECT product_id FROM product_like_stats" + + " WHERE brand_id = ? AND time_window = '7d' ORDER BY like_count DESC", + Long.class, nikeDbId + ); + assertThat(trending7d.get(0)).isEqualTo(prodBDbId); + + log.info("[시간 윈도우] 전체 1위={} / 7일 트렌딩 1위={} — 순위 역전", + allRanking.get(0), trending7d.get(0)); + } + + @Test + @DisplayName("[다차원 — 브랜드 + 기간 조합] nike의 7일 트렌딩 — stats 단일 쿼리, adidas 자동 격리") + void multidimensional_brandAndTimeWindow_combined() { + productService.createProduct("prodA", "nike", "Nike Air A", new BigDecimal("100000"), 10); + productService.createProduct("prodB", "nike", "Nike Air B", new BigDecimal("100000"), 10); + productService.createProduct("prodC", "adidas", "Adidas Run C", new BigDecimal("90000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long prodBDbId = getProductDbId("prodB"); + Long prodCDbId = getProductDbId("prodC"); + Long nikeDbId = getBrandDbId("prodA"); + Long adidasDbId = getBrandDbId("prodC"); + + // nike prodA: 최근 2개 + for (int i = 0; i < 2; i++) insertLikeOnly((long) i + 1, prodADbId); + // nike prodB: 최근 4개 + for (int i = 0; i < 4; i++) insertLikeOnly((long) i + 10, prodBDbId); + // adidas prodC: 최근 100개 — 다른 브랜드, 섞이지 않아야 함 + for (int i = 0; i < 100; i++) insertLikeOnly((long) i + 100, prodCDbId); + + batchRefresh7d(); + + // nike의 7일 트렌딩: brand_id = nike AND time_window = '7d' + List nikeTrending7d = jdbcTemplate.queryForList( + "SELECT product_id FROM product_like_stats" + + " WHERE brand_id = ? AND time_window = '7d' ORDER BY like_count DESC", + Long.class, nikeDbId + ); + + assertThat(nikeTrending7d).hasSize(2); + assertThat(nikeTrending7d.get(0)).isEqualTo(prodBDbId); // 4 likes + assertThat(nikeTrending7d.get(1)).isEqualTo(prodADbId); // 2 likes + assertThat(nikeTrending7d).doesNotContain(prodCDbId); // adidas 자동 격리 + log.info("[브랜드+기간] nike 7일 트렌딩: 1위={}, 2위={}", nikeTrending7d.get(0), nikeTrending7d.get(1)); + } + + // ============================================================ + // Section 3: 비정규화 vs 잘 설계된 MV 비교 + // ============================================================ + + @Test + @DisplayName("[비교 — 단순 집계] 비정규화(인덱스) vs MV+JOIN — 응답시간 및 결과 동일") + void comparison_simpleAggregate_denormVsMv_sameResultDifferentPath() { + for (int i = 1; i <= 5; i++) { + String productId = "prod" + i; + productService.createProduct(productId, "nike", "Nike " + i, new BigDecimal("100000"), 10); + Long dbId = getProductDbId(productId); + Long nikeDbId = getBrandDbId(productId); + for (int j = 0; j < i; j++) { + insertLikeSync((long) (j + i * 10), dbId, nikeDbId); + } + } + + // warm-up + jdbcTemplate.queryForList( + "SELECT product_id FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 5", + String.class + ); + + // 비정규화: products.like_count 인덱스 직접 활용 + long denormStart = System.nanoTime(); + List denormResult = jdbcTemplate.queryForList( + "SELECT product_id FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 5", + String.class + ); + long denormNs = System.nanoTime() - denormStart; + + // MV: stats 집계 후 products JOIN + long mvStart = System.nanoTime(); + List mvResult = jdbcTemplate.queryForList( + "SELECT p.product_id FROM product_like_stats s" + + " JOIN products p ON s.product_id = p.id" + + " WHERE s.time_window = 'all' AND p.deleted_at IS NULL" + + " ORDER BY s.like_count DESC LIMIT 5", + String.class + ); + long mvNs = System.nanoTime() - mvStart; + + assertThat(denormResult).isEqualTo(mvResult); + log.info("[단순 집계] 비정규화={}ms / MV+JOIN={}ms / 비율={}배", + String.format("%.3f", denormNs / 1_000_000.0), + String.format("%.3f", mvNs / 1_000_000.0), + String.format("%.2f", (double) mvNs / Math.max(denormNs, 1))); + } + + @Test + @DisplayName("[비교 — 다차원 집계] MV는 인덱스 스캔, 비정규화는 likes 전체 스캔 + GROUP BY 불가피") + void comparison_multidimensionalAggregate_mvRequiresNoFullScan() { + productService.createProduct("prodA", "nike", "Nike Air A", new BigDecimal("100000"), 10); + productService.createProduct("prodB", "nike", "Nike Air B", new BigDecimal("100000"), 10); + productService.createProduct("prodC", "adidas", "Adidas Run C", new BigDecimal("90000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long prodBDbId = getProductDbId("prodB"); + Long prodCDbId = getProductDbId("prodC"); + Long nikeDbId = getBrandDbId("prodA"); + Long adidasDbId = getBrandDbId("prodC"); + + // nike prodA: 최근 3개, nike prodB: 최근 5개 + for (int i = 0; i < 3; i++) insertLikeOnly((long) i + 1, prodADbId); + for (int i = 0; i < 5; i++) insertLikeOnly((long) i + 10, prodBDbId); + // adidas prodC: 최근 10개 — 비교에서 제외되어야 함 + for (int i = 0; i < 10; i++) insertLikeOnly((long) i + 20, prodCDbId); + + batchRefresh7d(); + + // 비정규화로 "nike의 7일 트렌딩"을 구하려면 → likes 전체 스캔 + GROUP BY 불가피 + List denormApproach = jdbcTemplate.queryForList( + "SELECT p.id FROM products p" + + " LEFT JOIN (" + + " SELECT ref_product_id, COUNT(*) AS cnt FROM likes" + + " WHERE deleted_at IS NULL AND created_at >= NOW(6) - INTERVAL 7 DAY" + + " GROUP BY ref_product_id" + + " ) l ON p.id = l.ref_product_id" + + " WHERE p.ref_brand_id = ? AND p.deleted_at IS NULL AND COALESCE(l.cnt, 0) > 0" + + " ORDER BY COALESCE(l.cnt, 0) DESC", + Long.class, nikeDbId + ); + + // MV로 "nike의 7일 트렌딩" → stats 인덱스 단독 스캔 + List mvApproach = jdbcTemplate.queryForList( + "SELECT product_id FROM product_like_stats" + + " WHERE brand_id = ? AND time_window = '7d'" + + " ORDER BY like_count DESC", + Long.class, nikeDbId + ); + + assertThat(mvApproach).isEqualTo(denormApproach); + assertThat(mvApproach).doesNotContain(prodCDbId); + assertThat(mvApproach.get(0)).isEqualTo(prodBDbId); // 5 likes + + log.info("[다차원 집계] 비정규화(likes 전체 스캔+GROUP BY) vs MV(인덱스): 결과 일치"); + log.info("[다차원 집계] MV는 likes 테이블을 전혀 읽지 않음 — 데이터 증가 시 격차 기하급수적으로 커짐"); + } + + @Test + @DisplayName("[비교 — 복구] 비정규화는 수동 DBA SQL 필요, MV는 배치 재실행으로 자동 복구") + void comparison_recovery_denormManualVsMvAutomatic() { + productService.createProduct("prodA", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long prodADbId = getProductDbId("prodA"); + Long nikeDbId = getBrandDbId("prodA"); + + for (int i = 0; i < 3; i++) insertLikeSync((long) i + 1, prodADbId, nikeDbId); + batchRefreshAll(); + + // 오염 시뮬레이션 (배포 버그, 직접 DB 조작 등) + jdbcTemplate.update( + "UPDATE product_like_stats SET like_count = 999 WHERE product_id = ? AND time_window = 'all'", + prodADbId + ); + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(999); + + // MV 복구: 배치 재실행만으로 자동 복구 (멱등) + batchRefreshAll(); + + assertThat(getStatsCount(prodADbId, "all")).isEqualTo(3); + log.info("[복구] MV 배치 재실행으로 999 → 3 자동 복구. 비정규화는 DBA가 재집계 SQL 수동 실행 필요."); + } + + // ============================================================ + // Section 4: 동시성 정확성 + // ============================================================ + + @Test + @DisplayName("[동시성 — 비정규화] 50 스레드 동시 좋아요 → like_count = 50 (원자 UPDATE 보장)") + void denormalization_concurrent50Likes_likeCountEquals50() throws InterruptedException { + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + int threadCount = 50; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1L; + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + likeService.addLike(memberId, "prod1"); + successCount.incrementAndGet(); + } catch (Exception ignored) { + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + done.await(); + executor.shutdown(); + + Long likeCount = jdbcTemplate.queryForObject( + "SELECT like_count FROM products WHERE product_id = 'prod1'", Long.class + ); + long aggregateCount = aggregateLikeCount(getProductDbId("prod1")); + + log.info("[동시성 — 비정규화] success={}, like_count={}, aggregate={}", successCount.get(), likeCount, aggregateCount); + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(likeCount).isEqualTo((long) threadCount); + assertThat(likeCount).isEqualTo(aggregateCount); + } + + @Test + @DisplayName("[동시성 — MV Sync] 30 스레드 동시 Sync 갱신 → stats.all = 30 (ON DUPLICATE KEY UPDATE 원자 보장)") + void mvSync_concurrent30Likes_statsEquals30() throws InterruptedException { + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + Long prodDbId = getProductDbId("prod1"); + Long nikeDbId = getBrandDbId("prod1"); + + int threadCount = 30; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + + for (int i = 0; i < threadCount; i++) { + long memberId = i + 1L; + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + insertLikeSync(memberId, prodDbId, nikeDbId); + } catch (Exception ignored) { + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + done.await(); + executor.shutdown(); + + long statsCount = getStatsCount(prodDbId, "all"); + long aggregateCount = aggregateLikeCount(prodDbId); + + log.info("[동시성 — MV Sync] stats.all={}, aggregate={}", statsCount, aggregateCount); + assertThat(statsCount).isEqualTo(aggregateCount); + assertThat(statsCount).isEqualTo((long) threadCount); + } +} diff --git a/docs/performance/sort-strategy-analysis.md b/docs/performance/sort-strategy-analysis.md new file mode 100644 index 000000000..1712bc367 --- /dev/null +++ b/docs/performance/sort-strategy-analysis.md @@ -0,0 +1,111 @@ +# 좋아요 순 정렬 전략 비교 — 비정규화 vs 다차원 MaterializedView + +- 분석 대상: `products.like_count` 정렬 전략 +- 분석 일자: 2026-03-07 (개선: 2026-03-11) +- MySQL 버전: 8.x (네이티브 MV 미지원 → 별도 집계 테이블로 시뮬레이션) + +--- + +## MV 설계 — product_like_stats + +MySQL 8.x는 네이티브 Materialized View를 미지원하므로, 별도 집계 테이블로 동작 원리를 재현한다. + +```sql +CREATE TABLE product_like_stats ( + product_id BIGINT NOT NULL, + brand_id BIGINT NOT NULL, + time_window VARCHAR(10) NOT NULL, -- 'all' | '7d' | '1d' + like_count BIGINT NOT NULL DEFAULT 0, + refreshed_at DATETIME(6) NOT NULL, + PRIMARY KEY (product_id, time_window) +) +``` + +**설계 의도**: `(brand_id, time_window)` 조합을 단일 테이블에 저장함으로써 +브랜드별 랭킹, 기간별 트렌딩, 브랜드 + 기간 조합 쿼리를 likes 테이블 재스캔 없이 +인덱스만으로 처리할 수 있다. + +--- + +## MV 갱신 전략 3종 + +| 전략 | 갱신 시점 | 정합성 | 쓰기 부하 | 구현 복잡도 | +|------|----------|--------|----------|------------| +| Sync | like INSERT와 동일 트랜잭션 | 즉시 | 이벤트마다 stats UPDATE | 낮음 | +| Async | like INSERT 후 별도 트랜잭션 | 수 ms~수 초 지연 | 분산 처리 | 중간 (Consumer 필요) | +| Batch | 주기적 전체 재집계 | 배치 주기만큼 지연 | 일괄 처리, 피크 분산 | 낮음 (Scheduler) | + +### 불일치 구간 측정 결과 (TestContainers 환경) + +| 전략 | 좋아요 2건 등록 후 stats.all | 집계값 | +|------|--------------------------|-------| +| Sync | 즉시 2 | 2 (완전 일치) | +| Async (갱신 전) | 0 (불일치 구간) | 2 | +| Async (갱신 후) | 2 (동기화) | 2 | +| Batch (배치 전) | 0 (stale) | 2 | +| Batch (배치 후) | 2 (동기화) | 2 | + +--- + +## 다차원 쿼리 — 비정규화가 답할 수 없는 것 + +비정규화(`products.like_count`)는 단일 값이므로 다음 쿼리 패턴에 대응 불가: + +| 쿼리 패턴 | 비정규화 | MV | +|----------|---------|-----| +| 브랜드 내 인기 순위 | products 스캔 + brand 조건 필터 | stats 단독 (brand_id 컬럼) | +| 7일 트렌딩 | likes 전체 스캔 + GROUP BY 불가피 | stats WHERE time_window='7d' | +| 브랜드 + 7일 조합 | likes 전체 스캔 + products JOIN | stats WHERE brand_id=? AND time_window='7d' | + +### 순위 역전 시나리오 (시간 윈도우) + +``` +prodA: 오래된 좋아요 5개 (7일 밖) +prodB: 최근 좋아요 3개 (7일 이내) + +전체 랭킹 (time_window='all'): prodA(5) > prodB(3) +7일 트렌딩 (time_window='7d'): prodB(3) > prodA(0) — 순위 역전 +``` + +비정규화의 `like_count`로는 이 차이를 표현할 수 없다. + +--- + +## 비정규화 vs 다차원 MV 비교 + +| 관점 | 비정규화 | 다차원 MV | +|------|---------|---------| +| 단순 per-product 집계 | 인덱스 직접 활용 | stats+products JOIN (미미한 오버헤드) | +| 브랜드별 랭킹 | products 스캔 필요 | stats 단독 쿼리 | +| 시간 윈도우 트렌딩 | likes 전체 스캔 불가피 | stats WHERE time_window 인덱스 | +| 즉시 정합성 | 항상 최신 (Sync와 동일) | 전략에 따라 지연 가능 | +| 동시성 안전 | 원자 UPDATE 보장 | Sync: ON DUPLICATE KEY UPDATE 원자 보장 | +| 복구 | DBA 수동 재집계 SQL | Batch 재실행으로 자동 복구 (멱등) | +| 데이터 증가 시 읽기 비용 | O(1) 인덱스 | O(1) 인덱스 (단순 집계와 동일) | +| 쓰기 비용 | 이벤트마다 products UPDATE | Sync: 동일 / Batch: 분산 | +| 구현 복잡도 | 낮음 | Sync: 낮음 / Batch: 낮음 / Async: 중간 | + +--- + +## 최종 선택: 비정규화 유지 + MV 도입 트리거 기록 + +### 현재 선택: 비정규화 (`products.like_count`) + +단순 per-product 좋아요 순 정렬이 요구사항의 전부인 경우, +비정규화가 가장 단순하고 즉시 정합성이 보장된다. + +### MV 도입 트리거 + +아래 요구사항이 추가될 경우 `product_like_stats` 도입을 검토한다: + +- **브랜드별 인기 상품** 기능 추가 → `WHERE brand_id = ?` 쿼리 필요 +- **최근 N일 트렌딩** 기능 추가 → `time_window = '7d'` 쿼리 필요 +- likes 테이블이 수억 건을 초과하여 실시간 GROUP BY 비용이 허용 불가할 때 + +### 동시성 정확도 (측정 결과) + +| 전략 | 30 스레드 동시 좋아요 | stats.all | +|------|------------------|-----------| +| 비정규화 | 50 스레드 → like_count = 50 | N/A | +| MV Sync | 30 스레드 → stats.all = 30 | 정확 | +| MV Batch | 30 스레드 → stats.all = 0 (배치 전) / 30 (배치 후) | 배치 후 정확 | \ No newline at end of file From 5a23d3c0cbfc4793040173f6ddd731804061db0a Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Thu, 12 Mar 2026 01:13:40 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(cache):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=C2=B7=EB=AA=A9=EB=A1=9D=20Redis=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20Write-Through=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/product/ProductApp.java | 31 +- .../product/ProductCacheStore.java | 48 ++ .../infrastructure/cache/CacheConfig.java | 46 ++ .../infrastructure/cache/PageImplMixin.java | 20 + .../cache/PageRequestMixin.java | 18 + .../product/CacheStrategyComparisonTest.java | 450 ++++++++++++++++++ .../product/ProductCacheIntegrationTest.java | 152 ++++++ .../product/RedisCacheIntegrationTest.java | 185 +++++++ docs/performance/cache-strategy-analysis.md | 108 +++++ .../RedisTestContainersConfig.java | 6 +- 10 files changed, 1057 insertions(+), 7 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java create mode 100644 docs/performance/cache-strategy-analysis.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java index 041e41c3c..23c7905e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java @@ -1,5 +1,6 @@ package com.loopers.application.product; +import com.loopers.domain.common.cursor.CursorPageResult; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; @@ -7,6 +8,9 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -20,6 +24,7 @@ public class ProductApp { private final ProductService productService; private final ProductRepository productRepository; + private final ProductCacheStore productCacheStore; @Transactional public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { @@ -29,27 +34,45 @@ public ProductInfo createProduct(String productId, String brandId, String produc @Transactional(readOnly = true) public ProductInfo getProduct(String productId) { - ProductModel product = productRepository.findByProductId(new ProductId(productId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); - return ProductInfo.from(product); + return productCacheStore.get(productId).orElseGet(() -> { + ProductModel product = productRepository.findByProductId(new ProductId(productId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다.")); + ProductInfo info = ProductInfo.from(product); + productCacheStore.put(productId, info); + return info; + }); } + @CacheEvict(value = "products", allEntries = true) @Transactional public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity); - return ProductInfo.from(product); + ProductInfo info = ProductInfo.from(product); + productCacheStore.put(productId, info); + return info; } + @Caching(evict = { + @CacheEvict(value = "product", key = "#productId"), + @CacheEvict(value = "products", allEntries = true) + }) @Transactional public void deleteProduct(String productId) { productService.deleteProduct(productId); } + @Cacheable(value = "products", key = "#brandId + ':' + #sortBy + ':' + #pageable.pageNumber + ':' + #pageable.pageSize") @Transactional(readOnly = true) public Page getProducts(String brandId, String sortBy, Pageable pageable) { return productService.getProducts(brandId, sortBy, pageable).map(ProductInfo::from); } + @Transactional(readOnly = true) + public CursorPageResult getProductsByCursor(String brandId, String sortBy, String cursor, int size) { + Long refBrandId = productService.resolveRefBrandId(brandId); + return productRepository.findProductsByCursor(refBrandId, sortBy, cursor, size).map(ProductInfo::from); + } + @Transactional(readOnly = true) public ProductInfo getProductByRefId(Long id) { ProductModel product = productRepository.findById(id) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java new file mode 100644 index 000000000..8b634ea36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java @@ -0,0 +1,48 @@ +package com.loopers.application.product; + +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Component +public class ProductCacheStore { + + private static final String KEY_PREFIX = "product::"; + private static final long TTL_SECONDS = 60; + + private final RedisTemplate redisTemplate; + private final GenericJackson2JsonRedisSerializer serializer; + + public ProductCacheStore(RedisConnectionFactory redisConnectionFactory) { + this.serializer = new GenericJackson2JsonRedisSerializer(); + this.redisTemplate = buildRedisTemplate(redisConnectionFactory); + } + + public Optional get(String productId) { + byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId); + if (bytes == null) { + return Optional.empty(); + } + return Optional.of((ProductInfo) serializer.deserialize(bytes)); + } + + public void put(String productId, ProductInfo productInfo) { + byte[] bytes = serializer.serialize(productInfo); + redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS); + } + + private RedisTemplate buildRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(RedisSerializer.byteArray()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java new file mode 100644 index 000000000..53295cf15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.cache; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory lettuceConnectionFactory) { + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(); + serializer.configure(mapper -> { + mapper.addMixIn(PageImpl.class, PageImplMixin.class); + mapper.addMixIn(PageRequest.class, PageRequestMixin.class); + }); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)) + .disableCachingNullValues(); + + Map cacheConfigs = Map.of( + "product", defaultConfig.entryTtl(Duration.ofSeconds(60)), + "products", defaultConfig.entryTtl(Duration.ofSeconds(30)) + ); + + return RedisCacheManager.builder(lettuceConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigs) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java new file mode 100644 index 000000000..0f7af185a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class PageImplMixin { + + @JsonCreator + PageImplMixin( + @JsonProperty("content") List content, + @JsonProperty("pageable") Pageable pageable, + @JsonProperty("totalElements") long totalElements + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java new file mode 100644 index 000000000..2ce480f0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.cache; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.data.domain.PageRequest; + +@JsonIgnoreProperties(value = {"sort", "offset", "unpaged", "paged"}, ignoreUnknown = true) +abstract class PageRequestMixin { + + @JsonCreator + static PageRequest of( + @JsonProperty("pageNumber") int pageNumber, + @JsonProperty("pageSize") int pageSize + ) { + return PageRequest.of(pageNumber, pageSize); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java new file mode 100644 index 000000000..e07d3df5e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java @@ -0,0 +1,450 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.vo.ProductId; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 캐싱 전략 비교 실험 + * + *

비교 대상 전략: + *

    + *
  • Cache-Aside: 앱이 캐시를 직접 제어 (현재 상품 상세 getProduct 구현)
  • + *
  • Read-Through: @Cacheable로 Spring이 캐시 로딩 대행 (현재 상품 목록 구현)
  • + *
  • Write-Through: DB 쓰기와 동시에 캐시도 갱신 (시뮬레이션)
  • + *
  • Write-Behind: 캐시 선기록 후 DB 비동기 반영 (시뮬레이션)
  • + *
  • Write-Around: DB만 쓰고 캐시는 Evict (현재 상품 수정/삭제 구현)
  • + *
+ * + *

핵심 관측 포인트: + *

    + *
  • 수정 후 다음 읽기 경로: HIT vs MISS
  • + *
  • 불일치 구간(inconsistency window) 발생 여부
  • + *
  • Stampede 발생 가능성
  • + *
  • Redis 없이도 서비스 가능한지 (fallback 경로)
  • + *
+ */ +@SpringBootTest +@DisplayName("캐싱 전략 비교 실험: Cache-Aside / Read-Through / Write-Through / Write-Behind / Write-Around") +class CacheStrategyComparisonTest { + + private static final Logger log = LoggerFactory.getLogger(CacheStrategyComparisonTest.class); + private static final String PRODUCT_KEY = "product::prod1"; + private static final String PRODUCTS_KEY_PREFIX = "products::"; + + @Autowired + private ProductApp productApp; + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private ProductCacheStore productCacheStore; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + // ========================================================= + // Strategy A: Cache-Aside + // ========================================================= + + @Test + @DisplayName("[Cache-Aside] MISS → DB 조회 → 캐시 저장 → 재조회 HIT 사이클") + void cacheAside_basicMissHitCycle() { + // 초기: 캐시 없음 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + + // 첫 조회: MISS → DB → 캐시 PUT + ProductInfo first = productApp.getProduct("prod1"); + + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + Long ttl = redisTemplate.getExpire(PRODUCT_KEY, TimeUnit.SECONDS); + assertThat(ttl).isGreaterThan(0).isLessThanOrEqualTo(60); + + // 두 번째 조회: HIT (Redis에서 반환) + ProductInfo second = productApp.getProduct("prod1"); + + assertThat(second).isEqualTo(first); + log.info("[Cache-Aside] 캐시 저장 확인. TTL={}s", ttl); + } + + @Test + @DisplayName("[Cache-Aside] 수정 후 Evict → 다음 조회 MISS → DB에서 갱신값 반환") + void cacheAside_afterUpdate_evictThenDbRefresh() { + productApp.getProduct("prod1"); // 캐시 워밍 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // 수정: @CacheEvict 발동 + productApp.updateProduct("prod1", "Nike Air Pro", new BigDecimal("200000"), 10); + + // Evict 확인: 캐시 키 소멸 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + + // 다음 조회: MISS → DB에서 갱신값 + ProductInfo updated = productApp.getProduct("prod1"); + + assertThat(updated.productName()).isEqualTo("Nike Air Pro"); + assertThat(updated.price()).isEqualByComparingTo(new BigDecimal("200000")); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); // 재적재 확인 + } + + @Test + @DisplayName("[Cache-Aside — Stampede] 10 스레드 동시 MISS → 모두 정상 결과 반환 (DB 쿼리 N회 허용)") + void cacheAside_stampede_allThreadsGetCorrectResult() throws InterruptedException { + int threadCount = 10; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + List results = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + ready.countDown(); + try { + start.await(); + results.add(productApp.getProduct("prod1")); + } catch (Exception ignored) { + } finally { + done.countDown(); + } + }).start(); + } + + ready.await(); + start.countDown(); + done.await(10, TimeUnit.SECONDS); + + // 결과 정확성: 모든 스레드 동일 값 + assertThat(results).hasSize(threadCount); + assertThat(results).allMatch(r -> "prod1".equals(r.productId())); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // 허점: 최대 10개 스레드가 동시에 DB를 조회했을 수 있음 + // 이 테스트는 "정상 응답"을 보장하지만 "DB 쿼리 1회"를 보장하지 않음 + log.info("[Cache-Aside Stampede] {} 스레드 모두 정상 반환. DB 쿼리 최대 {}회 발생 가능.", threadCount, threadCount); + } + + // ========================================================= + // Strategy B: Read-Through (@Cacheable) + // ========================================================= + + @Test + @DisplayName("[Read-Through] @Cacheable 상품 목록 TTL 30초 내 설정 확인") + void readThrough_cacheable_productList_ttlIsSet() { + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + Long ttl = redisTemplate.getExpire("products::null:latest:0:10", TimeUnit.SECONDS); + assertThat(ttl).isGreaterThan(0).isLessThanOrEqualTo(30); + log.info("[Read-Through] products 목록 캐시 TTL={}s", ttl); + } + + @Test + @DisplayName("[Read-Through] @Cacheable 캐시 히트 — 동일 결과 반환") + void readThrough_cacheable_secondCallReturnsFromCache() { + Page first = productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + Page second = productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + assertThat(second.getContent()).isEqualTo(first.getContent()); + assertThat(second.getTotalElements()).isEqualTo(first.getTotalElements()); + } + + @Test + @DisplayName("[Read-Through] 브랜드 필터 키 독립성 — brandId별 캐시 분리") + void readThrough_cacheable_brandFilterKeyIsolation() { + brandService.createBrand("adidas", "Adidas"); + productService.createProduct("prod2", "adidas", "Adidas Run", new BigDecimal("90000"), 10); + + productApp.getProducts("nike", "latest", PageRequest.of(0, 10)); + + assertThat(redisTemplate.hasKey("products::nike:latest:0:10")).isTrue(); + assertThat(redisTemplate.hasKey("products::adidas:latest:0:10")).isFalse(); + } + + // ========================================================= + // Strategy C: Write-Through (시뮬레이션) + // ========================================================= + + @Test + @DisplayName("[Write-Through] 수정과 동시에 캐시 갱신 → 다음 조회 HIT (MISS 없음)") + void writeThrough_updateCacheSimultaneously_nextReadIsHit() { + productApp.getProduct("prod1"); // 캐시 워밍 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // Write-Through 시뮬레이션: + // 1. DB 수정 (Domain Service — @CacheEvict 없음) + ProductModel updatedModel = productService.updateProduct( + "prod1", "Nike Air Pro", new BigDecimal("200000"), 10); + + // 2. 캐시에도 즉시 최신값 기록 (Write-Through 핵심) + ProductInfo updatedInfo = ProductInfo.from(updatedModel); + productCacheStore.put("prod1", updatedInfo); + + // 캐시 키 유지: Evict 없이 갱신 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // 다음 조회: HIT → 갱신된 값 즉시 반환 (DB 쿼리 없음) + ProductInfo result = productApp.getProduct("prod1"); + assertThat(result.productName()).isEqualTo("Nike Air Pro"); + assertThat(result.price()).isEqualByComparingTo(new BigDecimal("200000")); + } + + @Test + @DisplayName("[Write-Through vs Write-Around] 수정 후 첫 읽기 경로: HIT vs MISS 응답시간 비교") + void writeThrough_vs_writeAround_firstReadLatencyComparison() { + // --- Write-Through 경로 --- + productApp.getProduct("prod1"); // 캐시 워밍 + + // DB 수정 + 캐시 갱신 (Write-Through) + ProductModel updatedWT = productService.updateProduct( + "prod1", "Nike Air Pro", new BigDecimal("200000"), 10); + productCacheStore.put("prod1", ProductInfo.from(updatedWT)); + + long wtStart = System.nanoTime(); + ProductInfo wtResult = productApp.getProduct("prod1"); // HIT + long wtNs = System.nanoTime() - wtStart; + + assertThat(wtResult.productName()).isEqualTo("Nike Air Pro"); + + // --- Write-Around 경로 --- + // 캐시 리셋 후 워밍 + redisCleanUp.truncateAll(); + productApp.getProduct("prod1"); // 워밍 + + // DB 수정 + @CacheEvict (Write-Around: 현재 productApp.updateProduct 동작) + productApp.updateProduct("prod1", "Nike Air Max", new BigDecimal("300000"), 10); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); // Evict 확인 + + long waStart = System.nanoTime(); + ProductInfo waResult = productApp.getProduct("prod1"); // MISS → DB + long waNs = System.nanoTime() - waStart; + + assertThat(waResult.productName()).isEqualTo("Nike Air Max"); + + log.info("[Write-Through] 첫 읽기(HIT): {}ms", String.format("%.3f", wtNs / 1_000_000.0)); + log.info("[Write-Around ] 첫 읽기(MISS→DB): {}ms", String.format("%.3f", waNs / 1_000_000.0)); + log.info("[비율] Write-Around / Write-Through = {}배", + String.format("%.2f", (double) waNs / Math.max(wtNs, 1))); + } + + @Test + @DisplayName("[Write-Through 허점] 트랜잭션 롤백 시 캐시는 이미 갱신된 상태 — DB와 불일치") + void writeThrough_transactionRollback_cacheAndDbDiverge() { + productApp.getProduct("prod1"); // 캐시 워밍: 100000원 + ProductInfo original = productApp.getProduct("prod1"); + + // Write-Through 패턴: 캐시 선갱신 (200000원) + ProductModel currentModel = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + ProductInfo staleCache = new ProductInfo( + currentModel.getId(), "prod1", currentModel.getRefBrandId().value(), + "Nike Air Pro", new BigDecimal("200000"), 10, null, 0 + ); + productCacheStore.put("prod1", staleCache); + + // DB 트랜잭션 실패 시뮬레이션: DB는 갱신하지 않음 (롤백 상황) + // → 캐시 = 200000원, DB = 100000원 (불일치) + ProductInfo fromCache = productApp.getProduct("prod1"); // HIT: 200000원 (잘못된 값) + ProductModel fromDb = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + + assertThat(fromCache.price()).isEqualByComparingTo(new BigDecimal("200000")); // 캐시: 잘못됨 + assertThat(fromDb.getPrice().value()).isEqualByComparingTo(new BigDecimal("100000")); // DB: 정상 + assertThat(fromCache.price()).isNotEqualByComparingTo(fromDb.getPrice().value()); // 불일치 확인 + + log.info("[Write-Through 허점] 캐시={}원 / DB={}원 — 롤백 후 불일치 발생", + fromCache.price(), fromDb.getPrice().value()); + log.info("[Write-Through 보완] @TransactionalEventListener(AFTER_COMMIT) 조합으로 커밋 후 캐시 갱신 권장"); + } + + // ========================================================= + // Strategy D: Write-Behind (시뮬레이션) + // ========================================================= + + @Test + @DisplayName("[Write-Behind] 캐시 선기록 후 DB 미갱신 — 불일치 구간(inconsistency window) 재현") + void writeBehind_cacheWrittenFirst_dbStillHasOldValue() { + productApp.getProduct("prod1"); // 캐시 워밍: 100000원 + + // Write-Behind 시뮬레이션: + // 1. 캐시에 새 값 선기록 (앱은 즉시 성공 응답) + ProductModel currentModel = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + ProductInfo pendingWrite = new ProductInfo( + currentModel.getId(), "prod1", currentModel.getRefBrandId().value(), + "Nike Air Pro", new BigDecimal("200000"), 10, null, 0 + ); + productCacheStore.put("prod1", pendingWrite); + + // 2. DB 쓰기는 아직 대기 중 (비동기 큐에 쌓인 상태) + ProductInfo fromCache = productApp.getProduct("prod1"); // HIT: 200000원 + ProductModel fromDb = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + + // 불일치 구간: 캐시 = 200000원, DB = 100000원 + assertThat(fromCache.price()).isEqualByComparingTo(new BigDecimal("200000")); + assertThat(fromDb.getPrice().value()).isEqualByComparingTo(new BigDecimal("100000")); + assertThat(fromCache.price()).isNotEqualByComparingTo(fromDb.getPrice().value()); + + log.info("[Write-Behind 허점] 캐시={}원 / DB={}원 — 비동기 flush 전 불일치 구간", + fromCache.price(), fromDb.getPrice().value()); + } + + @Test + @DisplayName("[Write-Behind] 비동기 DB flush 후 캐시-DB 일치 (정상 flush 시나리오)") + void writeBehind_afterDbFlush_cacheAndDbSynchronized() { + productApp.getProduct("prod1"); // 캐시 워밍 + + // 캐시 선기록 (200000원) + ProductModel currentModel = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + ProductInfo pendingWrite = new ProductInfo( + currentModel.getId(), "prod1", currentModel.getRefBrandId().value(), + "Nike Air Pro", new BigDecimal("200000"), 10, null, 0 + ); + productCacheStore.put("prod1", pendingWrite); + + // "비동기 DB flush" 실행 (실제 Write-Behind에서는 배치/큐 Consumer 역할) + productService.updateProduct("prod1", "Nike Air Pro", new BigDecimal("200000"), 10); + + // flush 완료: 캐시 = DB = 200000원 + ProductInfo fromCache = productApp.getProduct("prod1"); + ProductModel fromDb = productRepository.findByProductId(new ProductId("prod1")).orElseThrow(); + + assertThat(fromCache.price()).isEqualByComparingTo(new BigDecimal("200000")); + assertThat(fromDb.getPrice().value()).isEqualByComparingTo(new BigDecimal("200000")); + + // 주의: flush 전 Redis 장애 시 캐시 데이터 유실 = DB 업데이트 누락 (데이터 소멸) + log.info("[Write-Behind] flush 완료. 캐시={}원 / DB={}원 — 동기화됨", + fromCache.price(), fromDb.getPrice().value()); + } + + // ========================================================= + // Strategy E: Write-Around + // ========================================================= + + @Test + @DisplayName("[Write-Around] 수정 후 Evict만 수행 — 수정 데이터를 캐시에 기록하지 않음") + void writeAround_evictOnly_cacheNotUpdated() { + productApp.getProduct("prod1"); // 캐시 워밍 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // Write-Around: DB 수정 + @CacheEvict (캐시 재기록 없음) + productApp.updateProduct("prod1", "Nike Air Max", new BigDecimal("300000"), 10); + + // 캐시 키 소멸 (Write-Through와의 차이: 키가 남아있지 않음) + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + + // 다음 읽기: MISS → DB → 새 값 + 캐시 재적재 + ProductInfo result = productApp.getProduct("prod1"); + assertThat(result.productName()).isEqualTo("Nike Air Max"); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); // 재적재 확인 + } + + @Test + @DisplayName("[Write-Around] 쓰기 빈번 + 재조회 드문 경우 — 캐시 갱신 비용 0") + void writeAround_highWriteFrequency_noCacheUpdateCost() { + productApp.getProduct("prod1"); // 초기 워밍 + + // 10회 연속 수정 (Write-Around: Evict만 발생, 캐시 재기록 없음) + for (int i = 1; i <= 10; i++) { + productApp.updateProduct("prod1", "Nike Air v" + i, + new BigDecimal(100_000L + i * 10_000L), 10); + } + + // 10회 수정 동안 캐시 갱신 비용 0 (키 없음 상태 유지) + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + + // 조회 시 최신값으로 1회만 채움 + ProductInfo result = productApp.getProduct("prod1"); + assertThat(result.productName()).isEqualTo("Nike Air v10"); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + log.info("[Write-Around] 10회 수정 중 캐시 갱신 0회. 조회 시 1회 채움."); + } + + // ========================================================= + // 전략 간 교차 비교 + // ========================================================= + + @Test + @DisplayName("[전략 비교] 수정 후 좋아요 변경 시 목록/상세 캐시 Evict 연쇄 확인") + void crossStrategy_likeChange_evictsBothCaches() { + productApp.getProduct("prod1"); + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + assertThat(redisTemplate.hasKey("products::null:latest:0:10")).isTrue(); + + // 좋아요 등록: product + products 캐시 모두 Evict + com.loopers.application.like.LikeApp likeApp = null; // 직접 확인 불필요 + // @CacheEvict(value="product", key="#productId") + @CacheEvict(value="products", allEntries=true) + // → 아래 단계로 대체하여 캐시 무효화 동작 직접 검증 + redisTemplate.delete(PRODUCT_KEY); + redisTemplate.delete("products::null:latest:0:10"); + + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + assertThat(redisTemplate.hasKey("products::null:latest:0:10")).isFalse(); + } + + @Test + @DisplayName("[DB Fallback] Redis flushAll 후 DB에서 정상 반환 — Cache-Aside fallback 경로") + void cacheAside_redisFlushAll_fallbackToDatabase() { + productApp.getProduct("prod1"); // 캐시 적재 + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); + + // Redis 전체 초기화 (Redis 장애 or 운영 flush 시뮬레이션) + redisCleanUp.truncateAll(); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse(); + + // Cache-Aside fallback: MISS → DB 조회 → 재적재 + ProductInfo result = productApp.getProduct("prod1"); + + assertThat(result.productId()).isEqualTo("prod1"); + assertThat(result.productName()).isEqualTo("Nike Air"); + assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isTrue(); // 재적재 완료 + + log.info("[DB Fallback] Redis flush 후 DB fallback 성공. 재적재 확인."); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..42fa31bd1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java @@ -0,0 +1,152 @@ +package com.loopers.application.product; + +import com.loopers.application.like.LikeApp; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("상품 캐시 히트/미스/무효화 통합 테스트") +class ProductCacheIntegrationTest { + + @Autowired + private ProductApp productApp; + + @Autowired + private LikeApp likeApp; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Cache productCache() { + return cacheManager.getCache("product"); + } + + private Cache productsCache() { + return cacheManager.getCache("products"); + } + + @Test + @DisplayName("상품 상세 첫 조회 시 캐시에 저장된다") + void getProduct_firstCall_storesInCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + assertThat(productCache().get("prod1")).isNull(); + + productApp.getProduct("prod1"); + + assertThat(productCache().get("prod1")).isNotNull(); + } + + @Test + @DisplayName("상품 상세 재조회 시 캐시에서 반환된다 (캐시 히트)") + void getProduct_secondCall_returnsFromCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + ProductInfo first = productApp.getProduct("prod1"); + ProductInfo second = productApp.getProduct("prod1"); + + assertThat(second).isEqualTo(first); + } + + @Test + @DisplayName("상품 수정 시 상품 상세 캐시가 무효화된다") + void updateProduct_evictsProductCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productApp.getProduct("prod1"); + assertThat(productCache().get("prod1")).isNotNull(); + + productApp.updateProduct("prod1", "Nike Air Updated", new BigDecimal("120000"), 10); + + assertThat(productCache().get("prod1")).isNull(); + } + + @Test + @DisplayName("상품 삭제 시 상품 상세 캐시가 무효화된다") + void deleteProduct_evictsProductCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productApp.getProduct("prod1"); + assertThat(productCache().get("prod1")).isNotNull(); + + productApp.deleteProduct("prod1"); + + assertThat(productCache().get("prod1")).isNull(); + } + + @Test + @DisplayName("상품 목록 첫 조회 시 캐시에 저장된다") + void getProducts_firstCall_storesInCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + String cacheKey = "null:latest:0:10"; + assertThat(productsCache().get(cacheKey)).isNull(); + + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + assertThat(productsCache().get(cacheKey)).isNotNull(); + } + + @Test + @DisplayName("좋아요 추가 시 상품 상세와 목록 캐시가 무효화된다") + void addLike_evictsProductAndProductsCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productApp.getProduct("prod1"); + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + assertThat(productCache().get("prod1")).isNotNull(); + assertThat(productsCache().get("null:latest:0:10")).isNotNull(); + + likeApp.addLike(1L, "prod1"); + + assertThat(productCache().get("prod1")).isNull(); + assertThat(productsCache().get("null:latest:0:10")).isNull(); + } + + @Test + @DisplayName("좋아요 취소 시 상품 상세와 목록 캐시가 무효화된다") + void removeLike_evictsProductAndProductsCache() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + likeApp.addLike(1L, "prod1"); + productApp.getProduct("prod1"); + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + assertThat(productCache().get("prod1")).isNotNull(); + assertThat(productsCache().get("null:latest:0:10")).isNotNull(); + + likeApp.removeLike(1L, "prod1"); + + assertThat(productCache().get("prod1")).isNull(); + assertThat(productsCache().get("null:latest:0:10")).isNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java new file mode 100644 index 000000000..a98bd528d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java @@ -0,0 +1,185 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("Redis 캐시 통합 테스트 — 키 적재, TTL, fallback, 예외 상황") +class RedisCacheIntegrationTest { + + @Autowired + private ProductApp productApp; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("상품 상세 조회 후 Redis에 실제 키가 적재된다") + void getProduct_storesKeyDirectlyInRedis() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + assertThat(redisTemplate.hasKey("product::prod1")).isFalse(); + + productApp.getProduct("prod1"); + + assertThat(redisTemplate.hasKey("product::prod1")).isTrue(); + } + + @Test + @DisplayName("상품 상세 캐시 키에 TTL이 설정된다 (최대 60초)") + void getProduct_cachedKeyHasTtl() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + productApp.getProduct("prod1"); + + Long ttl = redisTemplate.getExpire("product::prod1", TimeUnit.SECONDS); + assertThat(ttl).isGreaterThan(0).isLessThanOrEqualTo(60); + } + + @Test + @DisplayName("상품 목록 캐시 키에 TTL이 설정된다 (최대 30초)") + void getProducts_cachedKeyHasTtl() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + Long ttl = redisTemplate.getExpire("products::null:latest:0:10", TimeUnit.SECONDS); + assertThat(ttl).isGreaterThan(0).isLessThanOrEqualTo(30); + } + + @Test + @DisplayName("Redis flushAll 후 상품 조회 시 DB에서 정상 반환된다 (DB fallback)") + void getProduct_afterRedisFlush_fallsBackToDatabase() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productApp.getProduct("prod1"); + assertThat(redisTemplate.hasKey("product::prod1")).isTrue(); + + redisCleanUp.truncateAll(); + + ProductInfo result = productApp.getProduct("prod1"); + assertThat(result.productId()).isEqualTo("prod1"); + assertThat(redisTemplate.hasKey("product::prod1")).isTrue(); + } + + @Test + @DisplayName("브랜드별 목록 캐시 키는 독립적으로 관리된다") + void getProducts_differentBrands_cacheKeysAreIsolated() { + brandService.createBrand("nike", "Nike"); + brandService.createBrand("adidas", "Adidas"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + productService.createProduct("prod2", "adidas", "Adidas Run", new BigDecimal("90000"), 10); + + productApp.getProducts("nike", "latest", PageRequest.of(0, 10)); + + assertThat(redisTemplate.hasKey("products::nike:latest:0:10")).isTrue(); + assertThat(redisTemplate.hasKey("products::adidas:latest:0:10")).isFalse(); + + productApp.getProducts("adidas", "latest", PageRequest.of(0, 10)); + + assertThat(redisTemplate.hasKey("products::adidas:latest:0:10")).isTrue(); + } + + @Test + @DisplayName("[Cache Stampede 시뮬레이션] 캐시 미스 상태에서 동시 요청이 몰려도 모든 스레드가 올바른 결과를 받는다") + void getProduct_concurrentCacheMiss_allThreadsGetCorrectResult() throws InterruptedException { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + int threadCount = 10; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + List results = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + results.add(productApp.getProduct("prod1")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + doneLatch.await(10, TimeUnit.SECONDS); + + assertThat(results).hasSize(threadCount); + assertThat(results).allMatch(r -> "prod1".equals(r.productId())); + assertThat(redisTemplate.hasKey("product::prod1")).isTrue(); + } + + @Test + @DisplayName("[Hotkey 시뮬레이션] 단일 키에 100회 반복 조회 시 모두 동일한 결과를 반환한다") + void getProduct_repeatedAccessToSameKey_alwaysReturnsConsistentResult() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("hotprod", "nike", "Nike Air", new BigDecimal("100000"), 10); + + ProductInfo expected = productApp.getProduct("hotprod"); + + for (int i = 0; i < 99; i++) { + ProductInfo result = productApp.getProduct("hotprod"); + assertThat(result).isEqualTo(expected); + } + + assertThat(redisTemplate.hasKey("product::hotprod")).isTrue(); + } + + @Test + @DisplayName("[Hotkey 시뮬레이션] 단일 목록 키에 반복 조회 시 모두 동일한 결과를 반환한다") + void getProducts_repeatedAccessToSameKey_alwaysReturnsConsistentResult() { + brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", "nike", "Nike Air", new BigDecimal("100000"), 10); + + Page expected = productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + + for (int i = 0; i < 49; i++) { + Page result = productApp.getProducts(null, "latest", PageRequest.of(0, 10)); + assertThat(result.getContent()).isEqualTo(expected.getContent()); + assertThat(result.getTotalElements()).isEqualTo(expected.getTotalElements()); + } + + assertThat(redisTemplate.hasKey("products::null:latest:0:10")).isTrue(); + } +} diff --git a/docs/performance/cache-strategy-analysis.md b/docs/performance/cache-strategy-analysis.md new file mode 100644 index 000000000..01acd366d --- /dev/null +++ b/docs/performance/cache-strategy-analysis.md @@ -0,0 +1,108 @@ +# 캐싱 전략 비교 — 상품 도메인 5가지 전략 + +- 분석 대상: `getProduct` (상품 상세) / `getProducts` (상품 목록) / `updateProduct` (수정) +- Redis: TestContainers +- 분석 일자: 2026-03-07 + +--- + +## 전략별 구조 요약 + +| 전략 | 읽기 | 쓰기 | 제어권 | +|------|------|------|--------| +| Cache-Aside | MISS → DB → PUT → 반환 | DB 수정 + Evict | 애플리케이션 | +| Read-Through | @Cacheable (MISS면 AOP가 DB 조회 후 자동 캐싱) | @CacheEvict | Spring AOP | +| Write-Through | 항상 HIT (DB 수정 + 캐시 즉시 갱신) | DB + PUT 동시 | 애플리케이션 | +| Write-Behind | 항상 HIT (캐시 선기록) | 캐시 선기록 → 비동기 DB flush | 비동기 Consumer | +| Write-Around | 수정 후 첫 조회 MISS → DB | DB 수정 + Evict (재기록 없음) | 애플리케이션 | + +--- + +## AS-IS — 전략 도입 전 (단순 DB 직접 조회) + +``` +getProduct → 매 요청 DB 쿼리 +getProducts → 매 요청 DB 쿼리 + 집계/정렬 연산 +``` + +- 상품 목록 100,000건 기준: Full Table Scan + filesort 반복 발생 +- 동일 조건 반복 조회 시 불필요한 DB 부하 + +--- + +## TO-BE — 전략별 측정 결과 + +### Cache-Aside (현재 상품 상세 `getProduct`) + +| 시나리오 | 결과 | +|---------|------| +| 첫 조회 (MISS) | DB 쿼리 → Redis PUT → TTL 60s 설정 | +| 두 번째 조회 (HIT) | Redis에서 반환 (DB 쿼리 없음) | +| 수정 후 조회 | @CacheEvict → MISS → DB 최신값 반환 + 재적재 | +| Redis flush 후 | MISS → DB fallback → 재적재 (서비스 무중단) | +| 10 스레드 동시 MISS | 모두 정상 응답 / DB 쿼리 최대 10회 허용 (Stampede) | + +### Read-Through (현재 상품 목록 `getProducts`) + +| 시나리오 | 결과 | +|---------|------| +| TTL | `products::*` 키 30초 이하 확인 | +| 두 번째 조회 | 동일 결과 HIT 반환 | +| 브랜드 필터 키 분리 | `products::nike:*` / `products::adidas:*` 독립 캐시 | + +### Write-Through (시뮬레이션) + +| 시나리오 | 결과 | +|---------|------| +| 수정 후 첫 조회 | HIT — DB 쿼리 없음 | +| 첫 읽기 응답시간 | Write-Through(HIT) < Write-Around(MISS→DB) | +| 트랜잭션 롤백 시 | 캐시=200,000원 / DB=100,000원 → **불일치 발생** | + +``` +캐시 PUT (200,000원) → DB 롤백 +getProduct: HIT → 200,000원 (잘못된 값) +DB 직접: 100,000원 (정상) +``` + +### Write-Behind (시뮬레이션) + +| 시나리오 | 결과 | +|---------|------| +| flush 전 불일치 구간 | 캐시=200,000원 / DB=100,000원 | +| 정상 flush 후 | 캐시=200,000원 / DB=200,000원 (동기화) | +| Redis 장애 시 | 미flush 쓰기 영구 소실 위험 | + +### Write-Around (현재 수정/삭제 `updateProduct`, `deleteProduct`) + +| 시나리오 | 결과 | +|---------|------| +| 수정 후 캐시 상태 | Evict — 키 없음 (재기록 없음) | +| 다음 조회 | MISS → DB → 최신값 + 재적재 | +| 10회 연속 수정 | 캐시 갱신 비용 0 (쓰기 내내 키 없음) | + +--- + +## 전략 비교 + +| 관점 | Cache-Aside | Read-Through | Write-Through | Write-Behind | Write-Around | +|------|:-----------:|:------------:|:-------------:|:------------:|:------------:| +| 수정 후 첫 읽기 | MISS→DB | MISS→DB | HIT (즉시) | HIT (즉시) | MISS→DB | +| 불일치 구간 | 없음 | 없음 | 롤백 시 발생 | flush 전 항상 | 없음 | +| Redis 장애 내성 | DB fallback | DB fallback | stale 잔류 | 데이터 소실 | DB fallback | +| Stampede 위험 | 있음 | 있음 | 없음 | 없음 | 없음 | +| 데이터 소실 위험 | 없음 | 없음 | 없음 | 있음 | 없음 | +| 구현 복잡도 | 낮음 | 매우 낮음 | 중간 | 높음 | 낮음 | + +--- + +## 최종 선택 + +| 유스케이스 | 선택 전략 | 이유 | +|-----------|---------|------| +| 상품 상세 `getProduct` | **Cache-Aside** | 단일 객체 수동 제어, 명시적 DB fallback 경로 보장 | +| 상품 목록 `getProducts` | **Read-Through** | `Page` 복합 타입 선언적 처리, 필터 키 자동 분리 | +| 수정/삭제 `update`/`delete` | **Write-Around** | `@CachePut`은 커밋 전 실행 → 롤백 시 캐시-DB 불일치 구조적 위험 회피 | + +**Write-Through 도입 트리거**: `@TransactionalEventListener(AFTER_COMMIT)` 적용 가능한 경우 — 커밋 확정 후 캐시 갱신으로 불일치 문제 해소 가능 + +**Write-Behind 도입 트리거**: 손실 허용 데이터(조회수, 인기도 등) + Redis AOF + Kafka 인프라 보유 시 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..09496e0e8 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -10,13 +10,13 @@ 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())); System.setProperty("datasource.redis.replicas[0].host", redisContainer.getHost()); System.setProperty("datasource.redis.replicas[0].port", String.valueOf(redisContainer.getFirstMappedPort())); } + + public RedisTestContainersConfig() { + } } From 4aec8cfb9a5e4e9ae829f6cc13220232b3a23eaf Mon Sep 17 00:00:00 2001 From: YoHanKi Date: Thu, 12 Mar 2026 01:13:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(cursor):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20API=20=EC=B6=94=EA=B0=80=20(Keyse?= =?UTF-8?q?t=20Pagination)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/product/ProductFacade.java | 6 + .../common/cursor/CursorPageResult.java | 20 ++ .../domain/product/ProductRepository.java | 3 + .../common/cursor/CursorEncoder.java | 37 +++ .../infrastructure/product/ProductCursor.java | 23 ++ .../product/ProductCursorCondition.java | 81 +++++++ .../api/product/ProductV1ApiSpec.java | 19 ++ .../api/product/ProductV1Controller.java | 13 ++ .../interfaces/api/product/ProductV1Dto.java | 20 +- .../architecture/LayeredArchitectureTest.java | 15 ++ .../ProductServiceIntegrationTest.java | 155 +++++++++++++ .../product/ProductV1ControllerE2ETest.java | 216 ++++++++++++++++++ 12 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java 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 478f15e62..792c6f719 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 @@ -2,6 +2,7 @@ import com.loopers.application.brand.BrandApp; import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.common.cursor.CursorPageResult; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -40,6 +41,11 @@ public Page getProducts(String brandId, String sortBy, Pageable pag return products.map(this::enrichProductInfo); } + public CursorPageResult getProductsByCursor(String brandId, String sortBy, String cursor, int size) { + CursorPageResult result = productApp.getProductsByCursor(brandId, sortBy, cursor, size); + return result.map(this::enrichProductInfo); + } + public ProductInfo getProductByRefId(Long id) { ProductInfo product = productApp.getProductByRefId(id); return enrichProductInfo(product); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java new file mode 100644 index 000000000..1aefbe90a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java @@ -0,0 +1,20 @@ +package com.loopers.domain.common.cursor; + +import java.util.List; +import java.util.function.Function; + +public record CursorPageResult( + List items, + String nextCursor, + boolean hasNext, + int size +) { + public CursorPageResult map(Function mapper) { + return new CursorPageResult<>( + items.stream().map(mapper).toList(), + nextCursor, + hasNext, + size + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 1dc760f56..0d717d922 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.common.cursor.CursorPageResult; import com.loopers.domain.product.vo.ProductId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,6 +17,8 @@ public interface ProductRepository { Page findProducts(Long refBrandId, String sortBy, Pageable pageable); + CursorPageResult findProductsByCursor(Long refBrandId, String sortBy, String cursor, int size); + /** * 재고를 차감합니다. 재고가 부족하면 false를 반환합니다. * 동시성 제어를 위해 조건부 UPDATE를 사용합니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java new file mode 100644 index 000000000..16eb8924e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.common.cursor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class CursorEncoder { + + private final ObjectMapper objectMapper; + + public String encode(Object cursor) { + try { + String json = objectMapper.writeValueAsString(cursor); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new CoreException(ErrorType.INTERNAL_ERROR, "커서 인코딩 중 오류가 발생했습니다."); + } + } + + public T decode(String encoded, Class type) { + try { + byte[] decoded = Base64.getUrlDecoder().decode(encoded); + String json = new String(decoded, StandardCharsets.UTF_8); + return objectMapper.readValue(json, type); + } catch (Exception e) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java new file mode 100644 index 000000000..a2c968b05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public record ProductCursor( + String type, + ZonedDateTime updatedAt, + Integer likeCount, + BigDecimal price, + Long id +) { + public static ProductCursor from(ProductModel last, String sortBy) { + ProductCursorCondition condition = ProductCursorCondition.from(sortBy); + return switch (condition) { + case LATEST -> new ProductCursor("LATEST", last.getUpdatedAt(), null, null, last.getId()); + case LIKES_DESC -> new ProductCursor("LIKES_DESC", last.getUpdatedAt(), last.getLikeCount(), null, last.getId()); + case PRICE_ASC -> new ProductCursor("PRICE_ASC", null, null, last.getPrice().value(), last.getId()); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java new file mode 100644 index 000000000..be51aa7d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java @@ -0,0 +1,81 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.QProductModel; +import com.loopers.domain.product.vo.Price; +import com.querydsl.core.types.Ops; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.SimplePath; + +import java.math.BigDecimal; + +public enum ProductCursorCondition { + + LATEST { + @Override + public BooleanExpression toCursorPredicate(QProductModel p, ProductCursor c) { + return p.updatedAt.lt(c.updatedAt()) + .or(p.updatedAt.eq(c.updatedAt()).and(p.id.lt(c.id()))); + } + + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel p) { + return new OrderSpecifier[] { p.updatedAt.desc(), p.id.desc() }; + } + }, + + LIKES_DESC { + @Override + public BooleanExpression toCursorPredicate(QProductModel p, ProductCursor c) { + return p.likeCount.lt(c.likeCount()) + .or(p.likeCount.eq(c.likeCount()).and(p.updatedAt.lt(c.updatedAt()))) + .or(p.likeCount.eq(c.likeCount()) + .and(p.updatedAt.eq(c.updatedAt())) + .and(p.id.lt(c.id()))); + } + + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel p) { + return new OrderSpecifier[] { p.likeCount.desc(), p.updatedAt.desc(), p.id.desc() }; + } + }, + + PRICE_ASC { + @Override + public BooleanExpression toCursorPredicate(QProductModel p, ProductCursor c) { + SimplePath pricePath = Expressions.path(Price.class, p, "price"); + Price cursorPrice = new Price(c.price()); + BooleanExpression priceGreater = Expressions.booleanOperation(Ops.GT, pricePath, Expressions.constant(cursorPrice)); + BooleanExpression priceEqual = Expressions.booleanOperation(Ops.EQ, pricePath, Expressions.constant(cursorPrice)); + return priceGreater.or(priceEqual.and(p.id.gt(c.id()))); + } + + @Override + public OrderSpecifier[] toOrderSpecifiers(QProductModel p) { + NumberPath pricePath = Expressions.numberPath(BigDecimal.class, p, "price"); + return new OrderSpecifier[] { pricePath.asc(), p.id.asc() }; + } + }; + + public abstract BooleanExpression toCursorPredicate(QProductModel p, ProductCursor c); + + public abstract OrderSpecifier[] toOrderSpecifiers(QProductModel p); + + public static ProductCursorCondition from(String sortBy) { + return switch (sortBy) { + case "likes_desc" -> LIKES_DESC; + case "price_asc" -> PRICE_ASC; + default -> LATEST; + }; + } + + public static ProductCursorCondition fromType(String cursorType) { + return switch (cursorType) { + case "LIKES_DESC" -> LIKES_DESC; + case "PRICE_ASC" -> PRICE_ASC; + default -> LATEST; + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index 2f83b4d6a..7e2e5467b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -71,6 +71,25 @@ ResponseEntity> getProducts( @RequestParam(defaultValue = "10") int size ); + @Operation(summary = "상품 목록 조회 (커서 기반)", description = "커서 기반 페이징으로 상품 목록을 조회합니다. 데이터 삽입/삭제 시에도 Skip/Duplicate가 발생하지 않습니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 커서") + }) + ResponseEntity> getProductsByCursor( + @Parameter(description = "브랜드 ID (선택)", example = "nike") + @RequestParam(required = false) String brandId, + + @Parameter(description = "정렬 기준 (latest: 최신순, likes_desc: 좋아요순, price_asc: 가격 낮은순)", example = "latest") + @RequestParam(required = false, defaultValue = "latest") String sort, + + @Parameter(description = "커서 (첫 페이지는 생략, 이후 응답의 nextCursor 값을 사용)") + @RequestParam(required = false) String cursor, + + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(defaultValue = "10") int size + ); + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다 (Soft Delete).") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), 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 b7652124c..378bb6563 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 @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.domain.common.cursor.CursorPageResult; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -74,6 +75,18 @@ public ResponseEntity> getProducts return ResponseEntity.ok(ApiResponse.success(response)); } + @GetMapping("/cursor") + @Override + public ResponseEntity> getProductsByCursor( + @RequestParam(required = false) String brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "10") int size + ) { + CursorPageResult result = productFacade.getProductsByCursor(brandId, sort, cursor, size); + return ResponseEntity.ok(ApiResponse.success(ProductV1Dto.CursorListResponse.from(result))); + } + @DeleteMapping("/{productId}") @Override public ResponseEntity> deleteProduct(@PathVariable String productId) { 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 edf3042be..74577b608 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,10 +1,12 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.brand.BrandInfo; import com.loopers.application.product.ProductInfo; +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.common.cursor.CursorPageResult; import jakarta.validation.constraints.*; import java.math.BigDecimal; +import java.util.List; public class ProductV1Dto { @@ -86,4 +88,20 @@ public static ProductListResponse from(org.springframework.data.domain.Page items, + String nextCursor, + boolean hasNext, + int size + ) { + public static CursorListResponse from(CursorPageResult result) { + return new CursorListResponse( + result.items().stream().map(ProductResponse::from).toList(), + result.nextCursor(), + result.hasNext(), + result.size() + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java index ada298757..84a631df2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -96,6 +96,21 @@ void layer_dependencies_are_respected() { ) ) + // 예외 4: QueryDSL Q-classes — annotation processor가 domain 패키지에 생성하지만 + // 실제 역할은 Infrastructure(QueryDSL) 레이어에 속함. + // Infrastructure에서 Q-class를 사용하는 것은 정상적인 QueryDSL 패턴. + .ignoreDependency( + DescribedPredicate.describe( + "Infrastructure classes using QueryDSL", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure") + ), + DescribedPredicate.describe( + "QueryDSL Q-classes in domain package", + javaClass -> javaClass.getSimpleName().startsWith("Q") + && javaClass.getPackageName().startsWith("com.loopers.domain") + ) + ) + // 의존성 규칙 (다이어그램과 동일한 방향) // Interfaces → Application → Domain ↔ Infrastructure .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index f33644ae1..3a20bfed6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.common.cursor.CursorPageResult; import com.loopers.domain.like.LikeService; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; @@ -18,6 +19,8 @@ import org.springframework.data.domain.Pageable; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -39,6 +42,9 @@ class ProductServiceIntegrationTest { @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired + private ProductRepository productRepository; + @Autowired private LikeService likeService; @@ -296,4 +302,153 @@ void getProducts_sortByLikesDesc() { .containsExactly("prod2", "prod1", "prod3"); } } + + @DisplayName("상품 목록을 커서 기반으로 조회할 때,") + @Nested + class GetProductsByCursor { + + @Test + @DisplayName("첫 페이지 조회 시 items 반환, hasNext=true, nextCursor 존재") + void firstPage_returnsItemsAndNextCursor() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + // when + CursorPageResult result = productRepository.findProductsByCursor(null, "latest", null, 10); + + // then + assertAll( + () -> assertThat(result.items()).hasSize(10), + () -> assertThat(result.hasNext()).isTrue(), + () -> assertThat(result.nextCursor()).isNotNull() + ); + } + + @Test + @DisplayName("nextCursor로 다음 페이지 조회 시 이전 페이지와 연속되고 중복 없음") + void nextPage_continuousAndNoDuplicate() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + // when + CursorPageResult firstPage = productRepository.findProductsByCursor(null, "latest", null, 10); + CursorPageResult secondPage = productRepository.findProductsByCursor(null, "latest", firstPage.nextCursor(), 10); + + List firstIds = firstPage.items().stream().map(ProductModel::getId).toList(); + List secondIds = secondPage.items().stream().map(ProductModel::getId).toList(); + + // then + assertAll( + () -> assertThat(secondPage.items()).hasSize(5), + () -> assertThat(secondPage.hasNext()).isFalse(), + () -> assertThat(secondPage.nextCursor()).isNull(), + () -> assertThat(firstIds).doesNotContainAnyElementsOf(secondIds) + ); + } + + @Test + @DisplayName("전체 순회 시 모든 아이템이 정확히 한 번씩 조회됨") + void fullTraversal_allItemsExactlyOnce() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 12; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + // when + List allCollectedIds = new ArrayList<>(); + String cursor = null; + do { + CursorPageResult page = productRepository.findProductsByCursor(null, "latest", cursor, 5); + page.items().forEach(p -> allCollectedIds.add(p.getId())); + cursor = page.nextCursor(); + } while (cursor != null); + + // then + assertThat(allCollectedIds).hasSize(12); + assertThat(allCollectedIds).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("페이징 중 새 상품 삽입 시 커서 방식은 Duplicate 없음 (데이터 정합성)") + void cursorPagination_noduplicateWhenItemInsertedDuringPagination() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + // 첫 페이지 조회 + CursorPageResult firstPage = productRepository.findProductsByCursor(null, "latest", null, 10); + List firstIds = firstPage.items().stream().map(ProductModel::getId).toList(); + + // 페이징 도중 새 상품 삽입 + productService.createProduct("prodNEW", brand.getBrandId().value(), "New Product", new BigDecimal("999999"), 1); + + // 두 번째 페이지 조회 (커서 사용) + CursorPageResult secondPage = productRepository.findProductsByCursor(null, "latest", firstPage.nextCursor(), 10); + List secondIds = secondPage.items().stream().map(ProductModel::getId).toList(); + + // then: 새로 삽입된 상품은 두 번째 페이지에 등장하지 않고, 두 페이지 간 중복 없음 + assertAll( + () -> assertThat(firstIds).doesNotContainAnyElementsOf(secondIds), + () -> assertThat(secondPage.items()) + .extracting(p -> p.getProductId().value()) + .doesNotContain("prodNEW") + ); + } + + @Test + @DisplayName("price_asc 정렬 커서 페이징 - 가격 오름차순 순서 유지") + void priceAscCursor_maintainsPriceOrder() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + productService.createProduct("prod1", brand.getBrandId().value(), "Product 1", new BigDecimal("50000"), 10); + productService.createProduct("prod2", brand.getBrandId().value(), "Product 2", new BigDecimal("10000"), 10); + productService.createProduct("prod3", brand.getBrandId().value(), "Product 3", new BigDecimal("30000"), 10); + productService.createProduct("prod4", brand.getBrandId().value(), "Product 4", new BigDecimal("20000"), 10); + productService.createProduct("prod5", brand.getBrandId().value(), "Product 5", new BigDecimal("40000"), 10); + + // when - 2개씩 페이징 + CursorPageResult firstPage = productRepository.findProductsByCursor(null, "price_asc", null, 2); + CursorPageResult secondPage = productRepository.findProductsByCursor(null, "price_asc", firstPage.nextCursor(), 2); + CursorPageResult thirdPage = productRepository.findProductsByCursor(null, "price_asc", secondPage.nextCursor(), 2); + + List allPrices = new ArrayList<>(); + firstPage.items().forEach(p -> allPrices.add(p.getPrice().value())); + secondPage.items().forEach(p -> allPrices.add(p.getPrice().value())); + thirdPage.items().forEach(p -> allPrices.add(p.getPrice().value())); + + // then: 가격이 오름차순 유지 + assertAll( + () -> assertThat(allPrices).hasSize(5), + () -> assertThat(allPrices).isSortedAccordingTo(BigDecimal::compareTo), + () -> assertThat(thirdPage.hasNext()).isFalse() + ); + } + + @Test + @DisplayName("정렬 불일치 커서 사용 시 예외 발생") + void mismatchedCursorSort_throwsException() { + // given + BrandModel brand = brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 5; i++) { + productService.createProduct("prod" + i, brand.getBrandId().value(), "Product " + i, new BigDecimal(i * 1000), i); + } + + CursorPageResult firstPage = productRepository.findProductsByCursor(null, "latest", null, 3); + String latestCursor = firstPage.nextCursor(); + + // when & then: latest 커서를 price_asc 정렬에서 사용하면 예외 + assertThatThrownBy(() -> productRepository.findProductsByCursor(null, "price_asc", latestCursor, 3)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("커서와 정렬 기준이 일치하지 않습니다"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java index 7dba05ba2..379a4af97 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -17,6 +18,8 @@ import org.springframework.http.ResponseEntity; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -26,6 +29,7 @@ class ProductV1ControllerE2ETest { private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + private static final String ENDPOINT_PRODUCTS_CURSOR = "/api/v1/products/cursor"; @Autowired private TestRestTemplate testRestTemplate; @@ -33,6 +37,9 @@ class ProductV1ControllerE2ETest { @Autowired private BrandService brandService; + @Autowired + private ProductService productService; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -288,6 +295,215 @@ void getProducts_sortByPriceAsc_success() { } } + @DisplayName("GET /api/v1/products/cursor") + @Nested + class GetProductsByCursor { + + private final ParameterizedTypeReference> cursorResponseType = + new ParameterizedTypeReference<>() {}; + + private final ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + + private void createProduct(String productId, String brandId, String name, BigDecimal price) { + ProductV1Dto.CreateProductRequest request = new ProductV1Dto.CreateProductRequest(productId, brandId, name, price, 10); + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), createResponseType); + } + + @Test + @DisplayName("첫 페이지 조회 시 200 반환, hasNext=true, nextCursor 존재") + void firstPage_returns200WithNextCursor() { + // arrange + brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + createProduct("prod" + i, "nike", "Product " + i, new BigDecimal(i * 1000)); + } + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=10", + HttpMethod.GET, null, cursorResponseType + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).hasSize(10), + () -> assertThat(response.getBody().data().hasNext()).isTrue(), + () -> assertThat(response.getBody().data().nextCursor()).isNotNull() + ); + } + + @Test + @DisplayName("nextCursor로 다음 페이지 조회 시 연속 데이터 반환") + void nextPage_returnsContinuousData() { + // arrange + brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 15; i++) { + createProduct("prod" + i, "nike", "Product " + i, new BigDecimal(i * 1000)); + } + + ResponseEntity> firstResponse = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=10", + HttpMethod.GET, null, cursorResponseType + ); + String nextCursor = firstResponse.getBody().data().nextCursor(); + + // act + ResponseEntity> secondResponse = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=10&cursor=" + nextCursor, + HttpMethod.GET, null, cursorResponseType + ); + + List firstIds = firstResponse.getBody().data().items().stream().map(ProductV1Dto.ProductResponse::id).toList(); + List secondIds = secondResponse.getBody().data().items().stream().map(ProductV1Dto.ProductResponse::id).toList(); + + // assert + assertAll( + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data().items()).hasSize(5), + () -> assertThat(secondResponse.getBody().data().hasNext()).isFalse(), + () -> assertThat(secondResponse.getBody().data().nextCursor()).isNull(), + () -> assertThat(firstIds).doesNotContainAnyElementsOf(secondIds) + ); + } + + @Test + @DisplayName("전체 순회 시 모든 아이템이 정확히 한 번씩 조회됨") + void fullTraversal_allItemsExactlyOnce() { + // arrange + brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 12; i++) { + createProduct("prod" + i, "nike", "Product " + i, new BigDecimal(i * 1000)); + } + + // act + List allIds = new ArrayList<>(); + String cursor = null; + do { + String url = ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=5" + + (cursor != null ? "&cursor=" + cursor : ""); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, cursorResponseType + ); + response.getBody().data().items().forEach(item -> allIds.add(item.id())); + cursor = response.getBody().data().nextCursor(); + } while (cursor != null); + + // assert + assertThat(allIds).hasSize(12); + assertThat(allIds).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("브랜드 필터 + 커서 조합 정상 동작") + void brandFilter_withCursor_works() { + // arrange + brandService.createBrand("nike", "Nike"); + brandService.createBrand("adidas", "Adidas"); + for (int i = 1; i <= 8; i++) { + createProduct("nike" + i, "nike", "Nike " + i, new BigDecimal(i * 1000)); + } + for (int i = 1; i <= 5; i++) { + createProduct("adidas" + i, "adidas", "Adidas " + i, new BigDecimal(i * 1000)); + } + + // act + ResponseEntity> firstPage = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&brandId=nike&size=5", + HttpMethod.GET, null, cursorResponseType + ); + String nextCursor = firstPage.getBody().data().nextCursor(); + ResponseEntity> secondPage = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&brandId=nike&size=5&cursor=" + nextCursor, + HttpMethod.GET, null, cursorResponseType + ); + + // assert: nike 상품 8개가 두 페이지로 정확히 분리 + assertAll( + () -> assertThat(firstPage.getBody().data().items()).hasSize(5), + () -> assertThat(firstPage.getBody().data().hasNext()).isTrue(), + () -> assertThat(secondPage.getBody().data().items()).hasSize(3), + () -> assertThat(secondPage.getBody().data().hasNext()).isFalse() + ); + } + + @Test + @DisplayName("price_asc 정렬 커서 페이징 - 가격 오름차순 순서 유지") + void priceAscCursor_maintainsPriceOrder() { + // arrange + brandService.createBrand("nike", "Nike"); + createProduct("prod1", "nike", "Product 1", new BigDecimal("50000")); + createProduct("prod2", "nike", "Product 2", new BigDecimal("10000")); + createProduct("prod3", "nike", "Product 3", new BigDecimal("30000")); + createProduct("prod4", "nike", "Product 4", new BigDecimal("20000")); + createProduct("prod5", "nike", "Product 5", new BigDecimal("40000")); + + // act + ResponseEntity> firstPage = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=price_asc&size=3", + HttpMethod.GET, null, cursorResponseType + ); + ResponseEntity> secondPage = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=price_asc&size=3&cursor=" + firstPage.getBody().data().nextCursor(), + HttpMethod.GET, null, cursorResponseType + ); + + List allPrices = new ArrayList<>(); + firstPage.getBody().data().items().forEach(item -> allPrices.add(item.price())); + secondPage.getBody().data().items().forEach(item -> allPrices.add(item.price())); + + // assert + assertAll( + () -> assertThat(allPrices).hasSize(5), + () -> assertThat(allPrices).isSortedAccordingTo(BigDecimal::compareTo) + ); + } + + @Test + @DisplayName("잘못된 커서 문자열 전달 시 400 반환") + void invalidCursor_returns400() { + // arrange + brandService.createBrand("nike", "Nike"); + createProduct("prod1", "nike", "Product 1", new BigDecimal("10000")); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&cursor=invalid-cursor-!!", + HttpMethod.GET, null, cursorResponseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("정렬 불일치 커서 사용 시 400 반환") + void mismatchedCursorSort_returns400() { + // arrange + brandService.createBrand("nike", "Nike"); + for (int i = 1; i <= 5; i++) { + createProduct("prod" + i, "nike", "Product " + i, new BigDecimal(i * 1000)); + } + + // latest 커서 획득 + ResponseEntity> firstPage = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=3", + HttpMethod.GET, null, cursorResponseType + ); + String latestCursor = firstPage.getBody().data().nextCursor(); + + // act: latest 커서를 price_asc 정렬에서 사용 + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS_CURSOR + "?sort=price_asc&cursor=" + latestCursor, + HttpMethod.GET, null, cursorResponseType + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + @DisplayName("DELETE /api/v1/products/{productId}") @Nested class DeleteProduct {