From de303f21996f24062ae84d2a1b24b36f4a846f8d Mon Sep 17 00:00:00 2001 From: nuobasic Date: Fri, 13 Mar 2026 16:25:08 +0900 Subject: [PATCH 1/3] =?UTF-8?q?test(product):=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91,=20EXPLAIN=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C,=20like=5Fcount=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/ConcurrencyTest.java | 4 + .../application/product/ProductCacheTest.java | 244 ++++++++++++++++ .../loopers/domain/like/LikeServiceTest.java | 19 +- .../domain/product/ProductServiceTest.java | 10 +- .../product/ProductQueryExplainTest.java | 260 ++++++++++++++++++ .../interfaces/api/LikeV1ApiE2ETest.java | 23 +- .../interfaces/api/ProductV1ApiE2ETest.java | 60 +++- 7 files changed, 591 insertions(+), 29 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryExplainTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/ConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/ConcurrencyTest.java index 175b8ef0d..6827020e6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/ConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/ConcurrencyTest.java @@ -266,9 +266,11 @@ void allSucceed_whenDifferentUsersConcurrentlyLikeSameProduct() throws Interrupt ); // assert + ProductModel updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); assertThat(successCount.get()).isEqualTo(10); assertThat(failCount.get()).isEqualTo(0); assertThat(likeJpaRepository.countByProductId(product.getId())).isEqualTo(10); + assertThat(updatedProduct.getLikeCount()).isEqualTo(10L); } @DisplayName("같은 사용자가 10개 스레드에서 동시에 좋아요하면, 1건만 성공한다.") @@ -292,9 +294,11 @@ void allowsOnlyOneLike_whenSameUserConcurrentlyLikes() throws InterruptedExcepti ); // assert + ProductModel updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); assertThat(successCount.get()).isEqualTo(1); assertThat(failCount.get()).isEqualTo(9); assertThat(likeJpaRepository.countByProductId(product.getId())).isEqualTo(1); + assertThat(updatedProduct.getLikeCount()).isEqualTo(1L); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheTest.java new file mode 100644 index 000000000..a7abc93d4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheTest.java @@ -0,0 +1,244 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.data.domain.PageRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("상품 캐시 전략 검증") +class ProductCacheTest { + + @Autowired + private ProductFacade productFacade; + + @Autowired + private LikeService likeService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @DisplayName("상품 상세 캐시") + @Nested + class ProductDetailCache { + + @DisplayName("첫 번째 조회 시 캐시 미스가 발생하고, 두 번째 조회 시 캐시 히트로 동일한 결과를 반환한다.") + @Test + void returnsCachedResult_onSecondCall() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + ProductModel product = productJpaRepository.save( + new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE) + ); + + // act - 첫 번째 조회 (캐시 미스) + ProductInfo firstCall = productFacade.getProduct(product.getId()); + + // act - 두 번째 조회 (캐시 히트) + ProductInfo secondCall = productFacade.getProduct(product.getId()); + + // assert + assertAll( + () -> assertThat(firstCall.id()).isEqualTo(secondCall.id()), + () -> assertThat(firstCall.name()).isEqualTo(secondCall.name()), + () -> assertThat(firstCall.likeCount()).isEqualTo(secondCall.likeCount()), + () -> assertThat(cacheManager.getCache(ProductFacade.PRODUCT_DETAIL_CACHE)).isNotNull() + ); + } + + @DisplayName("상품 수정 후 캐시가 무효화되어 갱신된 데이터를 반환한다.") + @Test + void returnsFreshData_afterProductUpdate() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + ProductModel product = productJpaRepository.save( + new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE) + ); + productFacade.getProduct(product.getId()); // 캐시 적재 + + // act - 상품 수정 (캐시 무효화 발생) + productFacade.update(product.getId(), brand.getId(), "에어포스", 120000L, "새 설명", 50, ProductStatus.ON_SALE); + + // act - 수정 후 조회 + ProductInfo afterUpdate = productFacade.getProduct(product.getId()); + + // assert + assertAll( + () -> assertThat(afterUpdate.name()).isEqualTo("에어포스"), + () -> assertThat(afterUpdate.price()).isEqualTo(120000L) + ); + } + + @DisplayName("좋아요 등록 후 캐시가 무효화되어 갱신된 likeCount를 반환한다.") + @Test + void returnsFreshLikeCount_afterLike() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + ProductModel product = productJpaRepository.save( + new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE) + ); + ProductInfo beforeLike = productFacade.getProduct(product.getId()); + assertThat(beforeLike.likeCount()).isEqualTo(0L); + + // act - 좋아요 등록 (캐시 무효화) + likeService.like(1L, product.getId()); + + // act - 캐시 무효화 후 조회 + ProductInfo afterLike = productFacade.getProduct(product.getId()); + + // assert + assertThat(afterLike.likeCount()).isEqualTo(1L); + } + } + + @DisplayName("상품 목록 캐시") + @Nested + class ProductListCache { + + @DisplayName("동일한 조건으로 두 번 조회하면 캐시 히트로 동일한 결과를 반환한다.") + @Test + void returnsCachedList_onSecondCall() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE)); + productJpaRepository.save(new ProductModel(brand, "에어포스", 120000L, "설명", 50, ProductStatus.ON_SALE)); + + PageRequest pageable = PageRequest.of(0, 20); + + // act + ProductPageInfo firstCall = productFacade.getAll(pageable, ProductSortType.LATEST, null); + ProductPageInfo secondCall = productFacade.getAll(pageable, ProductSortType.LATEST, null); + + // assert + assertAll( + () -> assertThat(firstCall.totalElements()).isEqualTo(secondCall.totalElements()), + () -> assertThat(firstCall.content()).hasSameSizeAs(secondCall.content()) + ); + } + + @DisplayName("상품 등록 후 목록 캐시가 무효화되어 새 상품이 포함된다.") + @Test + void includesNewProduct_afterRegistration() { + // arrange + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE)); + + PageRequest pageable = PageRequest.of(0, 20); + ProductPageInfo beforeRegister = productFacade.getAll(pageable, ProductSortType.LATEST, null); + assertThat(beforeRegister.totalElements()).isEqualTo(1); + + // act - 상품 등록 (목록 캐시 무효화) + productFacade.register(brand.getId(), "에어포스", 120000L, "설명", 50, ProductStatus.ON_SALE); + + // act - 무효화 후 목록 조회 + ProductPageInfo afterRegister = productFacade.getAll(pageable, ProductSortType.LATEST, null); + + // assert + assertThat(afterRegister.totalElements()).isEqualTo(2); + } + + @DisplayName("브랜드 필터 + 좋아요순 정렬 캐시가 정상 동작한다.") + @Test + void cacheWorksWithBrandFilterAndLikesSort() { + // arrange + BrandModel nike = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + BrandModel adidas = brandJpaRepository.save(new BrandModel("아디다스", "스포츠 브랜드")); + ProductModel nikeProduct = productJpaRepository.save( + new ProductModel(nike, "나이키-1", 100000L, "설명", 100, ProductStatus.ON_SALE) + ); + productJpaRepository.save( + new ProductModel(adidas, "아디다스-1", 110000L, "설명", 100, ProductStatus.ON_SALE) + ); + likeService.like(1L, nikeProduct.getId()); + + PageRequest pageable = PageRequest.of(0, 20); + + // act + ProductPageInfo nikeProducts = productFacade.getAll(pageable, ProductSortType.LIKES_DESC, nike.getId()); + ProductPageInfo allProducts = productFacade.getAll(pageable, ProductSortType.LIKES_DESC, null); + + // assert + assertAll( + () -> assertThat(nikeProducts.totalElements()).isEqualTo(1), + () -> assertThat(allProducts.totalElements()).isEqualTo(2), + () -> assertThat(nikeProducts.content().get(0).likeCount()).isEqualTo(1L) + ); + } + } + + @DisplayName("캐시 미스 시에도 서비스가 정상 동작한다") + @Nested + class CacheMiss { + + @DisplayName("Redis 캐시가 비어 있어도 상품 상세 조회가 DB fallback으로 정상 동작한다.") + @Test + void detailQuery_worksWithoutCache() { + // arrange + redisCleanUp.truncateAll(); + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + ProductModel product = productJpaRepository.save( + new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE) + ); + + // act + ProductInfo result = productFacade.getProduct(product.getId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("Redis 캐시가 비어 있어도 상품 목록 조회가 DB fallback으로 정상 동작한다.") + @Test + void listQuery_worksWithoutCache() { + // arrange + redisCleanUp.truncateAll(); + BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 브랜드")); + productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "설명", 100, ProductStatus.ON_SALE)); + + PageRequest pageable = PageRequest.of(0, 20); + + // act + ProductPageInfo result = productFacade.getAll(pageable, ProductSortType.LATEST, null); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.totalElements()).isEqualTo(1) + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 83fddd2c6..c99936394 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -57,7 +57,7 @@ void createsLike_whenValidInfoIsProvided() { // arrange Long userId = 1L; Long productId = 1L; - given(productRepository.findById(productId)).willReturn(Optional.of(product)); + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.of(product)); given(likeRepository.findByUserIdAndProductId(userId, productId)).willReturn(Optional.empty()); given(likeRepository.save(any(LikeModel.class))).willAnswer(invocation -> invocation.getArgument(0)); @@ -67,7 +67,8 @@ void createsLike_whenValidInfoIsProvided() { // assert assertAll( () -> assertThat(result.getUserId()).isEqualTo(userId), - () -> assertThat(result.getProduct()).isEqualTo(product) + () -> assertThat(result.getProduct()).isEqualTo(product), + () -> assertThat(product.getLikeCount()).isEqualTo(1L) ); verify(likeRepository).save(any(LikeModel.class)); } @@ -79,7 +80,7 @@ void throwsConflictException_whenAlreadyLiked() { Long userId = 1L; Long productId = 1L; LikeModel existingLike = new LikeModel(userId, product); - given(productRepository.findById(productId)).willReturn(Optional.of(product)); + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.of(product)); given(likeRepository.findByUserIdAndProductId(userId, productId)).willReturn(Optional.of(existingLike)); // act @@ -97,7 +98,7 @@ void throwsConflictException_whenDataIntegrityViolationOccurs() { // arrange Long userId = 1L; Long productId = 1L; - given(productRepository.findById(productId)).willReturn(Optional.of(product)); + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.of(product)); given(likeRepository.findByUserIdAndProductId(userId, productId)).willReturn(Optional.empty()); given(likeRepository.save(any(LikeModel.class))).willThrow(new DataIntegrityViolationException("Duplicate entry")); @@ -116,7 +117,7 @@ void throwsNotFoundException_whenProductDoesNotExist() { // arrange Long userId = 1L; Long productId = 999L; - given(productRepository.findById(productId)).willReturn(Optional.empty()); + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.empty()); // act CoreException result = assertThrows(CoreException.class, () -> { @@ -139,13 +140,18 @@ void deletesLike_whenLikeExists() { Long userId = 1L; Long productId = 1L; LikeModel like = new LikeModel(userId, product); + product.increaseLikeCount(); + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.of(product)); given(likeRepository.findByUserIdAndProductId(userId, productId)).willReturn(Optional.of(like)); // act likeService.unlike(userId, productId); // assert - verify(likeRepository).delete(like); + assertAll( + () -> verify(likeRepository).delete(like), + () -> assertThat(product.getLikeCount()).isEqualTo(0L) + ); } @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 예외가 발생한다.") @@ -154,6 +160,7 @@ void throwsNotFoundException_whenLikeDoesNotExist() { // arrange Long userId = 1L; Long productId = 1L; + given(productRepository.findByIdForUpdate(productId)).willReturn(Optional.of(product)); given(likeRepository.findByUserIdAndProductId(userId, productId)).willReturn(Optional.empty()); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index 2c5979153..3f61a099e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -98,10 +98,10 @@ void returnsProductList_whenDefaultSort() { new ProductModel(brand, "에어포스", 120000L, "나이키 에어포스", 50, ProductStatus.ON_SALE) ); Page productPage = new PageImpl<>(products, pageable, products.size()); - given(productRepository.findAll(pageable)).willReturn(productPage); + given(productRepository.findAll(pageable, null)).willReturn(productPage); // act - Page result = productService.getAll(pageable, ProductSortType.LATEST); + Page result = productService.getAll(pageable, ProductSortType.LATEST, null); // assert assertThat(result.getContent()).hasSize(2); @@ -116,14 +116,14 @@ void returnsProductList_whenSortByLikesDesc() { new ProductModel(brand, "에어맥스", 150000L, "나이키 에어맥스", 100, ProductStatus.ON_SALE) ); Page productPage = new PageImpl<>(products, pageable, products.size()); - given(productRepository.findAllOrderByLikesDesc(pageable)).willReturn(productPage); + given(productRepository.findAllOrderByLikesDesc(pageable, null)).willReturn(productPage); // act - Page result = productService.getAll(pageable, ProductSortType.LIKES_DESC); + Page result = productService.getAll(pageable, ProductSortType.LIKES_DESC, null); // assert assertThat(result.getContent()).hasSize(1); - verify(productRepository).findAllOrderByLikesDesc(pageable); + verify(productRepository).findAllOrderByLikesDesc(pageable, null); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryExplainTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryExplainTest.java new file mode 100644 index 000000000..62d41235f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductQueryExplainTest.java @@ -0,0 +1,260 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("상품 조회 EXPLAIN 분석 — 인덱스 최적화 검증") +class ProductQueryExplainTest { + + private static final Logger log = LoggerFactory.getLogger(ProductQueryExplainTest.class); + private static final int BRAND_COUNT = 100; + private static final int PRODUCT_COUNT = 100_000; + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeAll + void setUp() { + databaseCleanUp.truncateAllTables(); + insertTestData(); + } + + @AfterAll + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private void insertTestData() { + log.info("=== 테스트 데이터 적재 시작: brands={}, products={} ===", BRAND_COUNT, PRODUCT_COUNT); + + List brands = new ArrayList<>(); + for (int i = 1; i <= BRAND_COUNT; i++) { + brands.add(new BrandModel("brand-" + i, "desc-" + i)); + } + brandJpaRepository.saveAll(brands); + brandJpaRepository.flush(); + + Random random = new Random(42); + int batchSize = 5000; + for (int batch = 0; batch < PRODUCT_COUNT / batchSize; batch++) { + List products = new ArrayList<>(); + for (int i = 0; i < batchSize; i++) { + int n = batch * batchSize + i + 1; + BrandModel brand = brands.get(n % BRAND_COUNT); + long price = 1000L + (n * 37L) % 500000L; + int stock = (n * 13) % 1000; + long likeCount = random.nextInt(500); + + ProductModel product = new ProductModel(brand, "product-" + n, price, "description-" + n, stock, ProductStatus.ON_SALE); + for (int lc = 0; lc < likeCount; lc++) { + product.increaseLikeCount(); + } + products.add(product); + } + productJpaRepository.saveAll(products); + productJpaRepository.flush(); + entityManager.clear(); + } + + log.info("=== 테스트 데이터 적재 완료 ==="); + } + + @DisplayName("UC1: 전체 + 최신순 — idx_products_deleted_created_id 사용 확인") + @Test + void explain_allProducts_sortByLatest() { + String sql = "EXPLAIN SELECT p.id, p.name, p.price, p.like_count " + + "FROM products p " + + "WHERE p.deleted_at IS NULL " + + "ORDER BY p.created_at DESC, p.id DESC " + + "LIMIT 20"; + + List result = executeExplain(sql); + logExplainResult("UC1: 전체 + 최신순", result); + + String possibleKeys = possibleKeys(result.get(0)); + assertThat(possibleKeys).contains("idx_products_deleted_created_id"); + } + + @DisplayName("UC2: 전체 + 가격 오름차순 — idx_products_deleted_price_id 사용 확인") + @Test + void explain_allProducts_sortByPriceAsc() { + String sql = "EXPLAIN SELECT p.id, p.name, p.price, p.like_count " + + "FROM products p " + + "WHERE p.deleted_at IS NULL " + + "ORDER BY p.price ASC, p.id ASC " + + "LIMIT 20"; + + List result = executeExplain(sql); + logExplainResult("UC2: 전체 + 가격 오름차순", result); + + String possibleKeys = possibleKeys(result.get(0)); + assertThat(possibleKeys).contains("idx_products_deleted_price_id"); + } + + @DisplayName("UC3: 전체 + 좋아요순 — idx_products_deleted_like_id 사용 확인") + @Test + void explain_allProducts_sortByLikesDesc() { + String sql = "EXPLAIN SELECT p.id, p.name, p.price, p.like_count " + + "FROM products p " + + "WHERE p.deleted_at IS NULL " + + "ORDER BY p.like_count DESC, p.id DESC " + + "LIMIT 20"; + + List result = executeExplain(sql); + logExplainResult("UC3: 전체 + 좋아요순", result); + + String extra = extra(result.get(0)); + assertThat(extra).doesNotContain("Using filesort"); + } + + @DisplayName("UC4: 브랜드 필터 + 좋아요순 — idx_products_brand_deleted_like_id 사용 확인") + @Test + void explain_brandFilter_sortByLikesDesc() { + String sql = "EXPLAIN SELECT p.id, p.name, p.price, p.like_count " + + "FROM products p " + + "WHERE p.deleted_at IS NULL " + + " AND p.brand_id = 10 " + + "ORDER BY p.like_count DESC, p.id DESC " + + "LIMIT 20"; + + List result = executeExplain(sql); + logExplainResult("UC4: 브랜드 필터 + 좋아요순", result); + + String key = key(result.get(0)); + assertThat(key).contains("idx_products_brand_deleted_like_id"); + } + + @DisplayName("AS-IS vs TO-BE 비교: 브랜드 필터 + 좋아요순 (JOIN+GROUP BY vs like_count)") + @Test + void compare_asIs_vs_toBe_brandFilter_likesDesc() { + // AS-IS: JOIN + GROUP BY + String asIsSql = "EXPLAIN SELECT p.id, p.name, COUNT(l.id) AS like_cnt " + + "FROM products p " + + "LEFT JOIN likes l ON l.product_id = p.id " + + "WHERE p.deleted_at IS NULL " + + " AND p.brand_id = 10 " + + "GROUP BY p.id " + + "ORDER BY like_cnt DESC, p.id DESC " + + "LIMIT 20"; + + // TO-BE: 비정규화 like_count + String toBeSql = "EXPLAIN SELECT p.id, p.name, p.like_count " + + "FROM products p " + + "WHERE p.deleted_at IS NULL " + + " AND p.brand_id = 10 " + + "ORDER BY p.like_count DESC, p.id DESC " + + "LIMIT 20"; + + List asIsResult = executeExplain(asIsSql); + List toBeResult = executeExplain(toBeSql); + + logExplainResult("AS-IS (JOIN + GROUP BY)", asIsResult); + logExplainResult("TO-BE (like_count 비정규화)", toBeResult); + + // TO-BE는 filesort 없이 인덱스만으로 처리 + String toBeExtra = extra(toBeResult.get(0)); + assertThat(toBeExtra).doesNotContain("Using filesort"); + } + + @SuppressWarnings("unchecked") + private List executeExplain(String sql) { + Query query = entityManager.createNativeQuery(sql); + return query.getResultList(); + } + + private void logExplainResult(String label, List rows) { + log.info("========== EXPLAIN: {} ==========", label); + log.info("{}", String.format("%-4s %-12s %-16s %-8s %-24s %-40s %-40s %-8s %-8s %-30s", + "id", "select_type", "table", "type", "possible_keys", "key", "key_len", "rows", "filtered", "Extra")); + for (Object[] row : rows) { + log.info("{}", String.format("%-4s %-12s %-16s %-8s %-24s %-40s %-40s %-8s %-8s %-30s", + id(row), selectType(row), table(row), type(row), + truncate(possibleKeys(row), 24), + truncate(key(row), 40), + truncate(keyLen(row), 40), + rows(row), filtered(row), + truncate(extra(row), 30))); + } + log.info("================================================"); + } + + private String id(Object[] row) { + return String.valueOf(row[0]); + } + + private String selectType(Object[] row) { + return String.valueOf(row[1]); + } + + private String table(Object[] row) { + return String.valueOf(row[2]); + } + + private String type(Object[] row) { + int index = row.length >= 12 ? 4 : 3; + return String.valueOf(row[index]); + } + + private String possibleKeys(Object[] row) { + int index = row.length >= 12 ? 5 : 4; + return String.valueOf(row[index]); + } + + private String key(Object[] row) { + int index = row.length >= 12 ? 6 : 5; + return String.valueOf(row[index]); + } + + private String keyLen(Object[] row) { + int index = row.length >= 12 ? 7 : 6; + return String.valueOf(row[index]); + } + + private String rows(Object[] row) { + int index = row.length >= 12 ? 9 : 8; + return String.valueOf(row[index]); + } + + private String filtered(Object[] row) { + int index = row.length >= 12 ? 10 : 9; + return String.valueOf(row[index]); + } + + private String extra(Object[] row) { + int index = row.length - 1; + return String.valueOf(row[index]); + } + + private String truncate(Object obj, int maxLen) { + String s = String.valueOf(obj); + return s.length() > maxLen ? s.substring(0, maxLen - 3) + "..." : s; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java index b1853bfba..6d218e883 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -2,12 +2,14 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductStatus; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.like.LikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; 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.Nested; @@ -31,7 +33,9 @@ class LikeV1ApiE2ETest { private final ProductJpaRepository productJpaRepository; private final BrandJpaRepository brandJpaRepository; private final LikeJpaRepository likeJpaRepository; + private final LikeService likeService; private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; @Autowired public LikeV1ApiE2ETest( @@ -39,18 +43,23 @@ public LikeV1ApiE2ETest( ProductJpaRepository productJpaRepository, BrandJpaRepository brandJpaRepository, LikeJpaRepository likeJpaRepository, - DatabaseCleanUp databaseCleanUp + LikeService likeService, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.productJpaRepository = productJpaRepository; this.brandJpaRepository = brandJpaRepository; this.likeJpaRepository = likeJpaRepository; + this.likeService = likeService; this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @DisplayName("POST /api/v1/products/{productId}/likes - 좋아요 등록") @@ -77,6 +86,7 @@ void createsLike_whenValidInfoIsProvided() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(likeJpaRepository.countByProductId(product.getId())).isEqualTo(1L); + assertThat(productJpaRepository.findById(product.getId()).orElseThrow().getLikeCount()).isEqualTo(1L); } @DisplayName("이미 좋아요한 상품이면, CONFLICT 응답을 받는다.") @@ -85,7 +95,7 @@ void returnsConflict_whenAlreadyLiked() { // arrange BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 의류 및 신발 브랜드")); ProductModel product = productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "나이키 에어맥스", 100, ProductStatus.ON_SALE)); - likeJpaRepository.save(new LikeModel(1L, product)); + likeService.like(1L, product.getId()); LikeRequest request = new LikeRequest(1L); // act @@ -131,7 +141,7 @@ void deletesLike_whenLikeExists() { // arrange BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 의류 및 신발 브랜드")); ProductModel product = productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "나이키 에어맥스", 100, ProductStatus.ON_SALE)); - likeJpaRepository.save(new LikeModel(1L, product)); + likeService.like(1L, product.getId()); LikeRequest request = new LikeRequest(1L); // act @@ -146,6 +156,7 @@ void deletesLike_whenLikeExists() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(likeJpaRepository.countByProductId(product.getId())).isEqualTo(0L); + assertThat(productJpaRepository.findById(product.getId()).orElseThrow().getLikeCount()).isEqualTo(0L); } @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 응답을 받는다.") @@ -181,8 +192,8 @@ void returnsLikedProducts_whenLikesExist() { BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 의류 및 신발 브랜드")); ProductModel product1 = productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "나이키 에어맥스", 100, ProductStatus.ON_SALE)); ProductModel product2 = productJpaRepository.save(new ProductModel(brand, "에어포스", 120000L, "나이키 에어포스", 50, ProductStatus.ON_SALE)); - likeJpaRepository.save(new LikeModel(1L, product1)); - likeJpaRepository.save(new LikeModel(1L, product2)); + likeService.like(1L, product1.getId()); + likeService.like(1L, product2.getId()); // act ParameterizedTypeReference>>> responseType = new ParameterizedTypeReference<>() {}; @@ -228,7 +239,7 @@ void excludesDeletedProducts() { ProductModel product2 = new ProductModel(brand, "삭제상품", 100000L, "삭제될 상품", 10, ProductStatus.ON_SALE); product2.delete(); product2 = productJpaRepository.save(product2); - likeJpaRepository.save(new LikeModel(1L, product1)); + likeService.like(1L, product1.getId()); likeJpaRepository.save(new LikeModel(1L, product2)); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index 1e03bc3e7..76647a6cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -1,13 +1,13 @@ package com.loopers.interfaces.api; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductStatus; import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.infrastructure.like.LikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; 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.Nested; @@ -34,27 +34,31 @@ class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final ProductJpaRepository productJpaRepository; private final BrandJpaRepository brandJpaRepository; - private final LikeJpaRepository likeJpaRepository; + private final LikeService likeService; private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; @Autowired public ProductV1ApiE2ETest( TestRestTemplate testRestTemplate, ProductJpaRepository productJpaRepository, BrandJpaRepository brandJpaRepository, - LikeJpaRepository likeJpaRepository, - DatabaseCleanUp databaseCleanUp + LikeService likeService, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp ) { this.testRestTemplate = testRestTemplate; this.productJpaRepository = productJpaRepository; this.brandJpaRepository = brandJpaRepository; - this.likeJpaRepository = likeJpaRepository; + this.likeService = likeService; this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; } @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @DisplayName("GET /api/v1/products/{productId} - 상품 상세 조회") @@ -67,8 +71,8 @@ void returnsProductWithBrandAndLikeCount_whenIdExists() { // arrange BrandModel brand = brandJpaRepository.save(new BrandModel("나이키", "스포츠 의류 및 신발 브랜드")); ProductModel product = productJpaRepository.save(new ProductModel(brand, "에어맥스", 150000L, "나이키 에어맥스", 100, ProductStatus.ON_SALE)); - likeJpaRepository.save(new LikeModel(1L, product)); - likeJpaRepository.save(new LikeModel(2L, product)); + likeService.like(1L, product.getId()); + likeService.like(2L, product.getId()); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -191,10 +195,10 @@ void returnsProductListSortedByLikesDesc() { ProductModel product2 = productJpaRepository.save(new ProductModel(brand, "에어포스", 120000L, "나이키 에어포스", 50, ProductStatus.ON_SALE)); // product2에 좋아요 3개, product1에 좋아요 1개 - likeJpaRepository.save(new LikeModel(1L, product2)); - likeJpaRepository.save(new LikeModel(2L, product2)); - likeJpaRepository.save(new LikeModel(3L, product2)); - likeJpaRepository.save(new LikeModel(1L, product1)); + likeService.like(1L, product2.getId()); + likeService.like(2L, product2.getId()); + likeService.like(3L, product2.getId()); + likeService.like(1L, product1.getId()); // act ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; @@ -214,6 +218,38 @@ void returnsProductListSortedByLikesDesc() { () -> assertThat(content.get(1).get("name")).isEqualTo("에어맥스") ); } + + @DisplayName("brandId 필터와 likes_desc 정렬을 함께 조회하면, 해당 브랜드 내 좋아요 순으로 반환한다.") + @Test + void returnsProductListFilteredByBrandAndSortedByLikesDesc() { + // arrange + BrandModel nike = brandJpaRepository.save(new BrandModel("나이키", "스포츠 의류 및 신발 브랜드")); + BrandModel adidas = brandJpaRepository.save(new BrandModel("아디다스", "스포츠 의류 및 신발 브랜드")); + ProductModel nike1 = productJpaRepository.save(new ProductModel(nike, "나이키-1", 100000L, "desc", 100, ProductStatus.ON_SALE)); + ProductModel nike2 = productJpaRepository.save(new ProductModel(nike, "나이키-2", 120000L, "desc", 100, ProductStatus.ON_SALE)); + ProductModel adidas1 = productJpaRepository.save(new ProductModel(adidas, "아디다스-1", 110000L, "desc", 100, ProductStatus.ON_SALE)); + likeService.like(10L, nike2.getId()); + likeService.like(11L, nike2.getId()); + likeService.like(20L, adidas1.getId()); + + // act + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?page=0&size=20&sort=likes_desc&brandId=" + nike.getId(), + HttpMethod.GET, + null, + responseType + ); + + // assert + List> content = (List>) response.getBody().data().get("content"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(content).hasSize(2), + () -> assertThat(content.get(0).get("name")).isEqualTo("나이키-2"), + () -> assertThat(content.get(1).get("name")).isEqualTo("나이키-1") + ); + } } record ProductResponse( From db6b13b4b6e04027d1e4c30c399e05903d102a51 Mon Sep 17 00:00:00 2001 From: nuobasic Date: Fri, 13 Mar 2026 16:27:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(product):=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=ED=95=84=ED=84=B0=EC=99=80=20like=5Fcount=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=B0=98=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 54 ++++++++++--------- .../application/product/ProductInfo.java | 6 ++- .../application/product/ProductPageInfo.java | 25 +++++++++ .../loopers/domain/product/ProductModel.java | 25 ++++++++- .../domain/product/ProductRepository.java | 4 +- .../domain/product/ProductService.java | 6 +-- .../product/ProductJpaRepository.java | 17 +++++- .../product/ProductRepositoryImpl.java | 12 +++-- .../api/product/ProductAdminV1ApiSpec.java | 3 +- .../api/product/ProductAdminV1Controller.java | 9 ++-- .../api/product/ProductAdminV1Dto.java | 23 ++++++++ .../api/product/ProductV1ApiSpec.java | 8 +-- .../api/product/ProductV1Controller.java | 12 ++--- .../interfaces/api/product/ProductV1Dto.java | 23 ++++++++ 14 files changed, 172 insertions(+), 55 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageInfo.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 15a8f2b2e..9efe97ea8 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 @@ -7,6 +7,9 @@ import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.ProductStatus; 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.PageRequest; import org.springframework.data.domain.Pageable; @@ -14,46 +17,53 @@ import org.springframework.stereotype.Component; import java.util.List; -import java.util.Map; @RequiredArgsConstructor @Component public class ProductFacade { + public static final String PRODUCT_DETAIL_CACHE = "productDetail"; + public static final String PRODUCT_LIST_CACHE = "productList"; + private final ProductService productService; private final LikeService likeService; + @Cacheable(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id") public ProductInfo getProduct(Long id) { ProductModel product = productService.getProduct(id); - long likeCount = likeService.getLikeCount(id); - return ProductInfo.from(product, likeCount); + return ProductInfo.from(product); } - public Page getAll(Pageable pageable, ProductSortType sortType) { + @Cacheable( + cacheNames = PRODUCT_LIST_CACHE, + key = "'product:list:brand:' + (#brandId == null ? 'all' : #brandId) + ':sort:' + #sortType.name() + ':page:' + #pageable.pageNumber + ':size:' + #pageable.pageSize" + ) + public ProductPageInfo getAll(Pageable pageable, ProductSortType sortType, Long brandId) { Pageable sortedPageable = applySorting(pageable, sortType); - Page products = productService.getAll(sortedPageable, sortType); - - List productIds = products.getContent().stream() - .map(ProductModel::getId) - .toList(); - - Map likeCounts = likeService.getLikeCountsByProductIds(productIds); - - return products.map(product -> ProductInfo.from(product, likeCounts.getOrDefault(product.getId(), 0L))); + Page products = productService.getAll(sortedPageable, sortType, brandId); + return ProductPageInfo.from(products.map(ProductInfo::from)); } + @CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true) public ProductInfo register(Long brandId, String name, Long price, String description, int stockQuantity, ProductStatus status) { ProductModel product = productService.register(brandId, name, price, description, stockQuantity, status); - return ProductInfo.from(product, 0L); + return ProductInfo.from(product); } + @Caching(evict = { + @CacheEvict(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id"), + @CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true) + }) public ProductInfo update(Long id, Long brandId, String name, Long price, String description, int stockQuantity, ProductStatus status) { ProductModel product = productService.update(id, brandId, name, price, description, stockQuantity, status); - long likeCount = likeService.getLikeCount(id); - return ProductInfo.from(product, likeCount); + return ProductInfo.from(product); } + @Caching(evict = { + @CacheEvict(cacheNames = PRODUCT_DETAIL_CACHE, key = "'product:detail:' + #id"), + @CacheEvict(cacheNames = PRODUCT_LIST_CACHE, allEntries = true) + }) public void delete(Long id) { productService.delete(id); } @@ -61,21 +71,15 @@ public void delete(Long id) { public List getMyLikedProducts(Long userId) { List likes = likeService.getMyLikes(userId); - List productIds = likes.stream() - .map(like -> like.getProduct().getId()) - .toList(); - - Map likeCounts = likeService.getLikeCountsByProductIds(productIds); - return likes.stream() - .map(like -> ProductInfo.from(like.getProduct(), likeCounts.getOrDefault(like.getProduct().getId(), 0L))) + .map(like -> ProductInfo.from(like.getProduct())) .toList(); } private Pageable applySorting(Pageable pageable, ProductSortType sortType) { return switch (sortType) { - case LATEST -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "createdAt")); - case PRICE_ASC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.ASC, "price")); + case LATEST -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id"))); + case PRICE_ASC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.ASC, "price").and(Sort.by(Sort.Direction.ASC, "id"))); case LIKES_DESC -> PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 61095c824..98c4f781d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -1,9 +1,11 @@ package com.loopers.application.product; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.loopers.domain.product.ProductModel; import java.time.ZonedDateTime; +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) public record ProductInfo( Long id, Long brandId, @@ -17,7 +19,7 @@ public record ProductInfo( ZonedDateTime createdAt, ZonedDateTime updatedAt ) { - public static ProductInfo from(ProductModel product, long likeCount) { + public static ProductInfo from(ProductModel product) { return new ProductInfo( product.getId(), product.getBrand().getId(), @@ -27,7 +29,7 @@ public static ProductInfo from(ProductModel product, long likeCount) { product.getDescription(), product.getStockQuantity(), product.getStatus().name(), - likeCount, + product.getLikeCount(), product.getCreatedAt(), product.getUpdatedAt() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageInfo.java new file mode 100644 index 000000000..d2e1c1f1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.product; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.springframework.data.domain.Page; + +import java.util.List; + +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +public record ProductPageInfo( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static ProductPageInfo from(Page page) { + return new ProductPageInfo( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} 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 20ad9d12b..fc1dd0438 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 @@ -11,7 +11,15 @@ import org.springframework.util.StringUtils; @Entity -@Table(name = "products") +@Table( + name = "products", + indexes = { + @Index(name = "idx_products_deleted_created_id", columnList = "deleted_at, created_at, id"), + @Index(name = "idx_products_deleted_price_id", columnList = "deleted_at, price, id"), + @Index(name = "idx_products_brand_deleted_like_id", columnList = "brand_id, deleted_at, like_count, id"), + @Index(name = "idx_products_deleted_like_id", columnList = "deleted_at, like_count, id") + } +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductModel extends BaseEntity { @@ -36,6 +44,9 @@ public class ProductModel extends BaseEntity { @Column(name = "status", nullable = false) private ProductStatus status; + @Column(name = "like_count", nullable = false) + private long likeCount; + public ProductModel(BrandModel brand, String name, Long price, String description, int stockQuantity, ProductStatus status) { validate(brand, name, price, stockQuantity, status); this.brand = brand; @@ -44,6 +55,7 @@ public ProductModel(BrandModel brand, String name, Long price, String descriptio this.description = description; this.stockQuantity = stockQuantity; this.status = status; + this.likeCount = 0L; } public void update(BrandModel brand, String name, Long price, String description, int stockQuantity, ProductStatus status) { @@ -66,6 +78,17 @@ public void deductStock(int quantity) { this.stockQuantity -= quantity; } + public void increaseLikeCount() { + this.likeCount += 1; + } + + public void decreaseLikeCount() { + if (this.likeCount <= 0) { + throw new CoreException(ErrorType.CONFLICT, "좋아요 수가 이미 0입니다."); + } + this.likeCount -= 1; + } + private void validate(BrandModel brand, String name, Long price, int stockQuantity, ProductStatus status) { if (brand == null) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 필수입니다."); 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 56c820962..5c74751fd 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 @@ -9,6 +9,6 @@ public interface ProductRepository { Optional findById(Long id); Optional findByIdForUpdate(Long id); ProductModel save(ProductModel product); - Page findAll(Pageable pageable); - Page findAllOrderByLikesDesc(Pageable pageable); + Page findAll(Pageable pageable, Long brandId); + Page findAllOrderByLikesDesc(Pageable pageable, Long brandId); } 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 a13c271b2..00a25247a 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 @@ -24,11 +24,11 @@ public ProductModel getProduct(Long id) { } @Transactional(readOnly = true) - public Page getAll(Pageable pageable, ProductSortType sortType) { + public Page getAll(Pageable pageable, ProductSortType sortType, Long brandId) { if (sortType == ProductSortType.LIKES_DESC) { - return productRepository.findAllOrderByLikesDesc(pageable); + return productRepository.findAllOrderByLikesDesc(pageable, brandId); } - return productRepository.findAll(pageable); + return productRepository.findAll(pageable, brandId); } @Transactional 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 f526a9dd8..3b159205d 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 @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; @@ -26,8 +27,20 @@ public interface ProductJpaRepository extends JpaRepository Page findAllByDeletedAtIsNull(Pageable pageable); @Query( - value = "SELECT p FROM ProductModel p JOIN FETCH p.brand LEFT JOIN LikeModel l ON l.product = p WHERE p.deletedAt IS NULL GROUP BY p ORDER BY COUNT(l) DESC", + value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL AND p.brand.id = :brandId", + countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL AND p.brand.id = :brandId" + ) + Page findAllByDeletedAtIsNullAndBrandId(@Param("brandId") Long brandId, Pageable pageable); + + @Query( + value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL ORDER BY p.likeCount DESC, p.id DESC", countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL" ) - Page findAllOrderByLikesDesc(Pageable pageable); + Page findAllOrderByLikeCountDesc(Pageable pageable); + + @Query( + value = "SELECT p FROM ProductModel p JOIN FETCH p.brand WHERE p.deletedAt IS NULL AND p.brand.id = :brandId ORDER BY p.likeCount DESC, p.id DESC", + countQuery = "SELECT COUNT(p) FROM ProductModel p WHERE p.deletedAt IS NULL AND p.brand.id = :brandId" + ) + Page findAllByBrandIdOrderByLikeCountDesc(@Param("brandId") Long brandId, Pageable pageable); } 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 40ac37961..02fb2f901 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 @@ -31,12 +31,18 @@ public ProductModel save(ProductModel product) { } @Override - public Page findAll(Pageable pageable) { + public Page findAll(Pageable pageable, Long brandId) { + if (brandId != null) { + return productJpaRepository.findAllByDeletedAtIsNullAndBrandId(brandId, pageable); + } return productJpaRepository.findAllByDeletedAtIsNull(pageable); } @Override - public Page findAllOrderByLikesDesc(Pageable pageable) { - return productJpaRepository.findAllOrderByLikesDesc(pageable); + public Page findAllOrderByLikesDesc(Pageable pageable, Long brandId) { + if (brandId != null) { + return productJpaRepository.findAllByBrandIdOrderByLikeCountDesc(brandId, pageable); + } + return productJpaRepository.findAllOrderByLikeCountDesc(pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java index 2f52b5076..ea1ecc87c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1ApiSpec.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @Tag(name = "Product Admin V1 API", description = "상품 관리자 API 입니다.") @@ -14,7 +13,7 @@ public interface ProductAdminV1ApiSpec { summary = "상품 목록 조회", description = "상품 목록을 페이징하여 조회합니다." ) - ApiResponse> getAll(Pageable pageable); + ApiResponse getAll(Pageable pageable); @Operation( summary = "상품 상세 조회", diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index 3d7e747c5..f8f0dd456 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -2,12 +2,12 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductPageInfo; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.ProductStatus; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @@ -20,10 +20,9 @@ public class ProductAdminV1Controller implements ProductAdminV1ApiSpec { @GetMapping @Override - public ApiResponse> getAll(Pageable pageable) { - Page response = productFacade.getAll(pageable, ProductSortType.LATEST) - .map(ProductAdminV1Dto.ProductResponse::from); - return ApiResponse.success(response); + public ApiResponse getAll(Pageable pageable) { + ProductPageInfo pageInfo = productFacade.getAll(pageable, ProductSortType.LATEST, null); + return ApiResponse.success(ProductAdminV1Dto.ProductListResponse.from(pageInfo)); } @GetMapping("/{productId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index 11643a7b0..7297ad2c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -1,14 +1,37 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductPageInfo; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.ZonedDateTime; +import java.util.List; public class ProductAdminV1Dto { + public record ProductListResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(ProductPageInfo pageInfo) { + List products = pageInfo.content().stream() + .map(ProductResponse::from) + .toList(); + return new ProductListResponse( + products, + pageInfo.page(), + pageInfo.size(), + pageInfo.totalElements(), + pageInfo.totalPages() + ); + } + } + public record RegisterRequest( @NotNull(message = "브랜드 ID는 필수입니다.") Long brandId, 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 50102a25f..f07ee59f5 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 @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @Tag(name = "Product V1 API", description = "상품 공개 API 입니다.") @@ -12,11 +11,12 @@ public interface ProductV1ApiSpec { @Operation( summary = "상품 목록 조회", - description = "상품 목록을 페이징하여 조회합니다. 정렬: latest, price_asc, likes_desc" + description = "상품 목록을 페이징하여 조회합니다. 정렬: latest, price_asc, likes_desc / 브랜드 필터: brandId" ) - ApiResponse> getAll( + ApiResponse getAll( Pageable pageable, - @Parameter(description = "정렬 조건 (latest, price_asc, likes_desc)", example = "latest") String sort + @Parameter(description = "정렬 조건 (latest, price_asc, likes_desc)", example = "latest") String sort, + @Parameter(description = "브랜드 ID 필터", example = "1") Long brandId ); @Operation( 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 309b51d15..e74d0f3e4 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,10 +2,10 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductPageInfo; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; @@ -18,14 +18,14 @@ public class ProductV1Controller implements ProductV1ApiSpec { @GetMapping @Override - public ApiResponse> getAll( + public ApiResponse getAll( Pageable pageable, - @RequestParam(defaultValue = "latest") String sort + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(required = false) Long brandId ) { ProductSortType sortType = ProductSortType.valueOf(sort.toUpperCase()); - Page response = productFacade.getAll(pageable, sortType) - .map(ProductV1Dto.ProductResponse::from); - return ApiResponse.success(response); + ProductPageInfo pageInfo = productFacade.getAll(pageable, sortType, brandId); + return ApiResponse.success(ProductV1Dto.ProductListResponse.from(pageInfo)); } @GetMapping("/{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 8ee5181a0..8c4bd0684 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,11 +1,34 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductPageInfo; import java.time.ZonedDateTime; +import java.util.List; public class ProductV1Dto { + public record ProductListResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(ProductPageInfo pageInfo) { + List products = pageInfo.content().stream() + .map(ProductResponse::from) + .toList(); + return new ProductListResponse( + products, + pageInfo.page(), + pageInfo.size(), + pageInfo.totalElements(), + pageInfo.totalPages() + ); + } + } + public record ProductResponse( Long id, Long brandId, From e954ff6b462322e363b350565e42ddd2c3789f02 Mon Sep 17 00:00:00 2001 From: nuobasic Date: Fri, 13 Mar 2026 16:28:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(cache):=20Redis=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20TTL/=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=98=A4=EB=A5=98=20=EC=8B=9C?= =?UTF-8?q?=20fallback=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/like/LikeModel.java | 6 +- .../com/loopers/domain/like/LikeService.java | 20 ++++- .../loopers/support/config/CacheConfig.java | 89 +++++++++++++++++++ http/commerce-api/product-v1.http | 3 + .../RedisTestContainersConfig.java | 2 - 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/CacheConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index 858c28646..a2bd5c6da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -12,7 +12,11 @@ @Entity @Table( name = "likes", - uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}), + indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id"), + @Index(name = "idx_likes_user_id", columnList = "user_id") + } ) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) 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 3318c677c..f664b8da0 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 @@ -5,6 +5,8 @@ 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.Caching; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,8 +22,12 @@ public class LikeService { private final ProductRepository productRepository; @Transactional + @Caching(evict = { + @CacheEvict(cacheNames = "productDetail", key = "'product:detail:' + #productId"), + @CacheEvict(cacheNames = "productList", allEntries = true) + }) public LikeModel like(Long userId, Long productId) { - ProductModel product = productRepository.findById(productId) + ProductModel product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); likeRepository.findByUserIdAndProductId(userId, productId) @@ -31,18 +37,28 @@ public LikeModel like(Long userId, Long productId) { LikeModel like = new LikeModel(userId, product); try { - return likeRepository.save(like); + LikeModel savedLike = likeRepository.save(like); + product.increaseLikeCount(); + return savedLike; } catch (DataIntegrityViolationException e) { throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); } } @Transactional + @Caching(evict = { + @CacheEvict(cacheNames = "productDetail", key = "'product:detail:' + #productId"), + @CacheEvict(cacheNames = "productList", allEntries = true) + }) public void unlike(Long userId, Long productId) { + ProductModel product = productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + LikeModel like = likeRepository.findByUserIdAndProductId(userId, productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요가 존재하지 않습니다.")); likeRepository.delete(like); + product.decreaseLikeCount(); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/CacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/CacheConfig.java new file mode 100644 index 000000000..82bb25f67 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/CacheConfig.java @@ -0,0 +1,89 @@ +package com.loopers.support.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cache.CacheManager; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class CacheConfig { + + private static final Logger log = LoggerFactory.getLogger(CacheConfig.class); + + private final ObjectMapper objectMapper; + + public CacheConfig(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Bean + public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { + ObjectMapper cacheObjectMapper = objectMapper.copy(); + cacheObjectMapper.findAndRegisterModules(); + cacheObjectMapper.activateDefaultTyping( + cacheObjectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + RedisSerializationContext.SerializationPair keySerializer = + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()); + RedisSerializationContext.SerializationPair valueSerializer = + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(cacheObjectMapper)); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(keySerializer) + .serializeValuesWith(valueSerializer) + .disableCachingNullValues(); + + Map cacheConfigurations = Map.of( + "productDetail", defaultConfig.entryTtl(Duration.ofMinutes(5)), + "productList", defaultConfig.entryTtl(Duration.ofMinutes(1)) + ); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Bean + public CacheErrorHandler cacheErrorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, org.springframework.cache.Cache cache, Object key) { + log.warn("Cache GET error. cache={}, key={}, fallback=DB", cache != null ? cache.getName() : "unknown", key, exception); + } + + @Override + public void handleCachePutError(RuntimeException exception, org.springframework.cache.Cache cache, Object key, Object value) { + log.warn("Cache PUT error. cache={}, key={}, requestContinues", cache != null ? cache.getName() : "unknown", key, exception); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, org.springframework.cache.Cache cache, Object key) { + log.warn("Cache EVICT error. cache={}, key={}, requestContinues", cache != null ? cache.getName() : "unknown", key, exception); + } + + @Override + public void handleCacheClearError(RuntimeException exception, org.springframework.cache.Cache cache) { + log.warn("Cache CLEAR error. cache={}, requestContinues", cache != null ? cache.getName() : "unknown", exception); + } + }; + } +} diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http index cc1f91f04..af537a42a 100644 --- a/http/commerce-api/product-v1.http +++ b/http/commerce-api/product-v1.http @@ -7,5 +7,8 @@ GET {{commerce-api}}/api/v1/products?page=0&size=20&sort=price_asc ### 상품 목록 조회 (좋아요 수 내림차순) GET {{commerce-api}}/api/v1/products?page=0&size=20&sort=likes_desc +### 상품 목록 조회 (브랜드 필터 + 좋아요 수 내림차순) +GET {{commerce-api}}/api/v1/products?page=0&size=20&sort=likes_desc&brandId=1 + ### 상품 상세 조회 GET {{commerce-api}}/api/v1/products/1 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..beeec0d8d 100644 --- a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java +++ b/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java @@ -10,9 +10,7 @@ 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()));