From af0d3926a5195050d68fd2ad9966e3d34d2af364 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 09:24:21 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=9C=20=C3=AC?= =?UTF-8?q?=C2=A0=EB=82=B4=EB=A6=BC=EC=B0=A8=EC=88=9C=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductSortType.java | 3 +- .../product/ProductFacadeTest.java | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index 822cba3c1..e72f616f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -6,7 +6,8 @@ public enum ProductSortType { LATEST(Sort.by(Sort.Direction.DESC, "createdAt")), // 또는 "createdAt" - PRICE_ASC(Sort.by(Sort.Direction.ASC, "price")); + PRICE_ASC(Sort.by(Sort.Direction.ASC, "price")), + LIKE_COUNT(Sort.by(Sort.Direction.DESC, "likeCount")); private final Sort sort; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index de1c9b699..e1900b755 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; import com.loopers.support.error.CoreException; @@ -117,6 +118,37 @@ void returnsProductInfoWithNullBrand_whenBrandNotFound() { assertThat(result.content().get(0).brand()).isNull(); } + @DisplayName("좋아요 순 정렬 시, likeCount 내림차순으로 정렬된 상품 목록이 반환된다.") + @Test + void returnsProductsSortedByLikeCountDesc_whenSortIsLikeCount() { + // arrange + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product productWithMoreLikes = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); + productWithMoreLikes.increaseLike(); + productWithMoreLikes.increaseLike(); + + Product productWithLessLikes = Product.of("나이키 줌", "설명", Stock.from(5), Price.from(100000), brandId); + productWithLessLikes.increaseLike(); + + PageRequest pageRequest = PageRequest.of(0, 20, ProductSortType.LIKE_COUNT.getSort()); + when(productRepository.findAll(pageRequest)).thenReturn( + new PageResponse<>(List.of(productWithMoreLikes, productWithLessLikes), 0, 20, 1) + ); + when(brandRepository.findAllByIdIn(List.of(brandId, brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = productFacade.getList(PageRequest.of(0, 20), null, "like_count"); + + // assert + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).likeCount()).isEqualTo(2L); + assertThat(result.content().get(1).likeCount()).isEqualTo(1L); + } + @DisplayName("등록된 상품이 없으면, 빈 목록을 반환한다.") @Test void returnsEmptyList_whenNoProducts() { From 19a207f09613b088ac0f62ff097241147ff241d3 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 10:41:01 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=BA=90=EC=8B=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductCacheStore.java | 12 ++ .../application/product/ProductFacade.java | 14 +- .../product/RedisProductCacheStore.java | 48 ++++++ .../product/ProductFacadeTest.java | 59 ++++++- .../api/product/ProductApiE2ETest.java | 160 ++++++++++++++++++ 5 files changed, 291 insertions(+), 2 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/product/RedisProductCacheStore.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java 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..518e93ae9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java @@ -0,0 +1,12 @@ +package com.loopers.application.product; + +import java.util.Optional; + +public interface ProductCacheStore { + + Optional get(Long productId); + + void put(Long productId, ProductInfo productInfo); + + void evict(Long productId); +} 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 dda5770ec..a37800f82 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 @@ -28,6 +28,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final LikeRepository likeRepository; private final ProductAssembler productAssembler; + private final ProductCacheStore productCachePort; @Transactional public void register(String name, String description, Integer stock, Integer price, Long brandId) { @@ -49,6 +50,8 @@ public void update(Long productId, String name, String description, Integer stoc Stock stockVo = Stock.from(stock); Price priceVo = Price.from(price); product.update(name, description, stockVo, priceVo); + + productCachePort.evict(productId); } @Transactional(readOnly = true) @@ -86,11 +89,20 @@ public PageResponse getList(Pageable pageable, Long brandId, String @Transactional(readOnly = true) public ProductInfo getDetail(Long productId) { + Optional cached = productCachePort.get(productId); + if (cached.isPresent()) { + return cached.get(); + } + Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); Optional optionalBrand = brandRepository.findById(product.brandId()); - return ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); + ProductInfo productInfo = ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); + + productCachePort.put(productId, productInfo); + + return productInfo; } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java new file mode 100644 index 000000000..73353fbf0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java @@ -0,0 +1,48 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductCacheStore; +import com.loopers.application.product.ProductInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +@Component +public class RedisProductCacheStore implements ProductCacheStore { + + private static final String KEY_PREFIX = "product:detail:"; + private static final long TTL_MINUTES = 5; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(Long productId) { + try { + String cached = redisTemplate.opsForValue().get(KEY_PREFIX + productId); + if (cached != null) { + return Optional.of(objectMapper.readValue(cached, ProductInfo.class)); + } + } catch (Exception ignored) { + } + return Optional.empty(); + } + + @Override + public void put(Long productId, ProductInfo productInfo) { + try { + String json = objectMapper.writeValueAsString(productInfo); + redisTemplate.opsForValue().set(KEY_PREFIX + productId, json, TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception ignored) { + } + } + + @Override + public void evict(Long productId) { + redisTemplate.delete(KEY_PREFIX + productId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index e1900b755..9907868ff 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -32,7 +33,8 @@ class ProductFacadeTest { ProductRepository productRepository = mock(ProductRepository.class); LikeRepository likeRepository = mock(LikeRepository.class); ProductAssembler productAssembler = new ProductAssembler(); - ProductFacade productFacade = new ProductFacade(brandRepository, productRepository, likeRepository, productAssembler); + ProductCacheStore productCachePort = mock(ProductCacheStore.class); + ProductFacade productFacade = new ProductFacade(brandRepository, productRepository, likeRepository, productAssembler, productCachePort); @DisplayName("상품 등록 시, ") @Nested @@ -177,6 +179,7 @@ void returnsProductInfo_whenProductExists() { Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); Brand brand = Brand.of("나이키", null); + when(productCachePort.get(productId)).thenReturn(Optional.empty()); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); @@ -196,6 +199,7 @@ void returnsProductInfoWithNullBrand_whenBrandNotFound() { Long productId = 1L; Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), 999L); + when(productCachePort.get(productId)).thenReturn(Optional.empty()); when(productRepository.findById(productId)).thenReturn(Optional.of(product)); when(brandRepository.findById(999L)).thenReturn(Optional.empty()); @@ -211,6 +215,7 @@ void returnsProductInfoWithNullBrand_whenBrandNotFound() { void throwsCoreException_whenProductNotFound() { // arrange Long productId = 999L; + when(productCachePort.get(productId)).thenReturn(Optional.empty()); when(productRepository.findById(productId)).thenReturn(Optional.empty()); // act @@ -221,6 +226,43 @@ void throwsCoreException_whenProductNotFound() { // assert assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); } + + @DisplayName("캐시 HIT 시, DB 조회 없이 캐시된 ProductInfo 를 반환한다.") + @Test + void returnsCachedProductInfo_whenCacheHit() { + // arrange + Long productId = 1L; + ProductInfo cached = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + when(productCachePort.get(productId)).thenReturn(Optional.of(cached)); + + // act + ProductInfo result = productFacade.getDetail(productId); + + // assert + assertThat(result.name()).isEqualTo("나이키 에어맥스"); + assertThat(result.brand()).isEqualTo("나이키"); + verify(productRepository, never()).findById(productId); + } + + @DisplayName("캐시 MISS 시, DB 에서 조회 후 캐시에 저장한다.") + @Test + void savesToCache_whenCacheMiss() { + // arrange + Long productId = 1L; + Long brandId = 1L; + Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); + Brand brand = Brand.of("나이키", null); + + when(productCachePort.get(productId)).thenReturn(Optional.empty()); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // act + productFacade.getDetail(productId); + + // assert + verify(productCachePort).put(eq(productId), any(ProductInfo.class)); + } } @DisplayName("상품 수정 시, ") @@ -257,6 +299,21 @@ void throwsCoreException_whenProductNotFound() { // assert assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); } + + @DisplayName("상품 수정 시, 캐시가 삭제된다.") + @Test + void evictsCache_whenProductUpdated() { + // arrange + Long productId = 1L; + Product product = mock(Product.class); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + + // act + productFacade.update(productId, "나이키 줌", "새 설명", 20, 200000); + + // assert + verify(productCachePort).evict(productId); + } } @DisplayName("상품 삭제 시, ") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java new file mode 100644 index 000000000..ea65709c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -0,0 +1,160 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.testcontainers.RedisTestContainersConfig; +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; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@Import(RedisTestContainersConfig.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/products"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetDetail { + + @DisplayName("최초 조회 시 (캐시 MISS), 200 응답과 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenCacheMiss() { + // arrange + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product = productJpaRepository.save( + Product.of("나이키 에어맥스", "편안한 운동화", Stock.from(10), Price.from(150000), brand.getId()) + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/" + product.getId(), HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키 에어맥스"), + () -> assertThat(response.getBody().data().brand()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().price()).isEqualTo(150000) + ); + } + + @DisplayName("두 번째 조회 시 (캐시 HIT), 200 응답과 동일한 상품 정보를 반환한다.") + @Test + void returnsSameProductInfo_whenCacheHit() { + // arrange + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product = productJpaRepository.save( + Product.of("나이키 에어맥스", "편안한 운동화", Stock.from(10), Price.from(150000), brand.getId()) + ); + String url = ENDPOINT + "/" + product.getId(); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // act (캐시 HIT) + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + String cacheKey = "product:detail:" + product.getId(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키 에어맥스"), + () -> assertThat(redisTemplate.hasKey(cacheKey)).isTrue() + ); + } + + @DisplayName("상품 수정 후 조회 시, 변경된 상품 정보를 반환한다.") + @Test + void returnsUpdatedProductInfo_afterCacheEviction() { + // arrange + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product = productJpaRepository.save( + Product.of("나이키 에어맥스", "편안한 운동화", Stock.from(10), Price.from(150000), brand.getId()) + ); + String detailUrl = ENDPOINT + "/" + product.getId(); + String updateUrl = ADMIN_ENDPOINT + "/" + product.getId(); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(detailUrl, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + HttpHeaders adminHeaders = new HttpHeaders(); + adminHeaders.set("X-Loopers-Ldap", "loopers.admin"); + ProductAdminDto.UpdateRequest updateRequest = + new ProductAdminDto.UpdateRequest("나이키 줌", "가벼운 운동화", 5, 120000); + + // act + testRestTemplate.exchange(updateUrl, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders), Void.class); + ResponseEntity> response = + testRestTemplate.exchange(detailUrl, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키 줌"), + () -> assertThat(response.getBody().data().price()).isEqualTo(120000) + ); + } + + @DisplayName("존재하지 않는 상품 ID 로 조회 시, 404 응답을 반환한다.") + @Test + void returnsNotFound_whenProductNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT + "/999999", HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From c5da28b5cd71a11a4e2c9fa6c4710ae72b5e91b5 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 11:24:55 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=BA=90=EC=8B=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductCacheStore.java | 10 +++ .../application/product/ProductFacade.java | 66 +++++++++++--- .../product/RedisProductCacheStore.java | 60 +++++++++++-- .../product/ProductFacadeTest.java | 87 +++++++++++++++++++ .../api/product/ProductApiE2ETest.java | 60 +++++++++++++ 5 files changed, 266 insertions(+), 17 deletions(-) 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 index 518e93ae9..22224a665 100644 --- 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 @@ -1,5 +1,7 @@ package com.loopers.application.product; +import java.util.List; +import java.util.Map; import java.util.Optional; public interface ProductCacheStore { @@ -9,4 +11,12 @@ public interface ProductCacheStore { void put(Long productId, ProductInfo productInfo); void evict(Long productId); + + record ProductListCacheEntry(List productIds, int totalPages) {} + + Optional getList(Long brandId, String sort, int page, int size); + + void putList(Long brandId, String sort, int page, int size, List productIds, int totalPages); + + Map multiGet(List productIds); } 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 a37800f82..b8bc89192 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 @@ -17,7 +17,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; @RequiredArgsConstructor @@ -28,7 +31,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final LikeRepository likeRepository; private final ProductAssembler productAssembler; - private final ProductCacheStore productCachePort; + private final ProductCacheStore productCacheStore; @Transactional public void register(String name, String description, Integer stock, Integer price, Long brandId) { @@ -51,7 +54,7 @@ public void update(Long productId, String name, String description, Integer stoc Price priceVo = Price.from(price); product.update(name, description, stockVo, priceVo); - productCachePort.evict(productId); + productCacheStore.evict(productId); } @Transactional(readOnly = true) @@ -69,27 +72,66 @@ public PageResponse getList(Pageable pageable) { @Transactional(readOnly = true) public PageResponse getList(Pageable pageable, Long brandId, String sort) { - PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), ProductSortType.from(sort).getSort()); - PageResponse products; - if (brandId == null) { - products = productRepository.findAll(pageRequest); - } else { - products = productRepository.findAllByBrandId(brandId, pageRequest); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort()); + + Optional listCache = productCacheStore.getList(brandId, sort, page, size); + if (listCache.isPresent()) { + List productIds = listCache.get().productIds(); + int totalPages = listCache.get().totalPages(); + if (productIds.isEmpty()) { + return new PageResponse<>(List.of(), page, size, totalPages); + } + Map cachedInfos = productCacheStore.multiGet(productIds); + List missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); + if (missingIds.isEmpty()) { + return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); + } + Map allInfoMap = new HashMap<>(cachedInfos); + List missingProducts = productRepository.findAllByIdIn(missingIds); + List missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); + List missingBrands = brandRepository.findAllByIdIn(missingBrandIds); + List missingInfos = productAssembler.toInfos(missingProducts, missingBrands); + for (int i = 0; i < missingProducts.size(); i++) { + Long productId = missingProducts.get(i).getId(); + ProductInfo info = missingInfos.get(i); + allInfoMap.put(productId, info); + productCacheStore.put(productId, info); + } + List orderedInfos = productIds.stream() + .map(allInfoMap::get) + .filter(Objects::nonNull) + .toList(); + return new PageResponse<>(orderedInfos, page, size, totalPages); } + PageResponse products = brandId == null + ? productRepository.findAll(pageRequest) + : productRepository.findAllByBrandId(brandId, pageRequest); + List productList = products.content(); - if (productList.isEmpty()) return new PageResponse<>(List.of(), products.page(), products.size(), products.totalPages()); + if (productList.isEmpty()) { + productCacheStore.putList(brandId, sort, page, size, List.of(), products.totalPages()); + return new PageResponse<>(List.of(), products.page(), products.size(), products.totalPages()); + } List brandIds = productList.stream().map(Product::brandId).toList(); List brands = brandRepository.findAllByIdIn(brandIds); - List productInfos = productAssembler.toInfos(productList, brands); + + List productIds = productList.stream().map(Product::getId).toList(); + productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); + for (int i = 0; i < productList.size(); i++) { + productCacheStore.put(productList.get(i).getId(), productInfos.get(i)); + } + return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); } @Transactional(readOnly = true) public ProductInfo getDetail(Long productId) { - Optional cached = productCachePort.get(productId); + Optional cached = productCacheStore.get(productId); if (cached.isPresent()) { return cached.get(); } @@ -100,7 +142,7 @@ public ProductInfo getDetail(Long productId) { Optional optionalBrand = brandRepository.findById(product.brandId()); ProductInfo productInfo = ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); - productCachePort.put(productId, productInfo); + productCacheStore.put(productId, productInfo); return productInfo; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java index 73353fbf0..f746b2d27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java @@ -7,6 +7,9 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -14,8 +17,10 @@ @Component public class RedisProductCacheStore implements ProductCacheStore { - private static final String KEY_PREFIX = "product:detail:"; - private static final long TTL_MINUTES = 5; + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String LIST_KEY_PREFIX = "product:list:"; + private static final long DETAIL_TTL_MINUTES = 5; + private static final long LIST_TTL_SECONDS = 30; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -23,7 +28,7 @@ public class RedisProductCacheStore implements ProductCacheStore { @Override public Optional get(Long productId) { try { - String cached = redisTemplate.opsForValue().get(KEY_PREFIX + productId); + String cached = redisTemplate.opsForValue().get(DETAIL_KEY_PREFIX + productId); if (cached != null) { return Optional.of(objectMapper.readValue(cached, ProductInfo.class)); } @@ -36,13 +41,58 @@ public Optional get(Long productId) { public void put(Long productId, ProductInfo productInfo) { try { String json = objectMapper.writeValueAsString(productInfo); - redisTemplate.opsForValue().set(KEY_PREFIX + productId, json, TTL_MINUTES, TimeUnit.MINUTES); + redisTemplate.opsForValue().set(DETAIL_KEY_PREFIX + productId, json, DETAIL_TTL_MINUTES, TimeUnit.MINUTES); } catch (Exception ignored) { } } @Override public void evict(Long productId) { - redisTemplate.delete(KEY_PREFIX + productId); + redisTemplate.delete(DETAIL_KEY_PREFIX + productId); + } + + @Override + public Optional getList(Long brandId, String sort, int page, int size) { + try { + String cached = redisTemplate.opsForValue().get(listKey(brandId, sort, page, size)); + if (cached != null) { + return Optional.of(objectMapper.readValue(cached, ProductListCacheEntry.class)); + } + } catch (Exception ignored) { + } + return Optional.empty(); + } + + @Override + public void putList(Long brandId, String sort, int page, int size, List productIds, int totalPages) { + try { + String json = objectMapper.writeValueAsString(new ProductListCacheEntry(productIds, totalPages)); + redisTemplate.opsForValue().set(listKey(brandId, sort, page, size), json, LIST_TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception ignored) { + } + } + + @Override + public Map multiGet(List productIds) { + List keys = productIds.stream() + .map(id -> DETAIL_KEY_PREFIX + id) + .toList(); + List values = redisTemplate.opsForValue().multiGet(keys); + Map result = new HashMap<>(); + if (values == null) return result; + for (int i = 0; i < productIds.size(); i++) { + String value = values.get(i); + if (value != null) { + try { + result.put(productIds.get(i), objectMapper.readValue(value, ProductInfo.class)); + } catch (Exception ignored) { + } + } + } + return result; + } + + private String listKey(Long brandId, String sort, int page, int size) { + return LIST_KEY_PREFIX + "brandId=" + brandId + ":sort=" + sort + ":page=" + page + ":size=" + size; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index 9907868ff..ef29b8c1c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -16,6 +16,7 @@ import org.springframework.data.domain.PageRequest; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -164,6 +165,92 @@ void returnsEmptyList_whenNoProducts() { // assert assertThat(result.content()).isEmpty(); } + + @DisplayName("목록 캐시 HIT + detail 전체 HIT 시, DB 조회 없이 캐시된 상품 목록을 반환한다.") + @Test + void returnsCachedList_whenListCacheHitAndAllDetailCacheHit() { + // arrange + Long brandId = 1L; + Long productId = 10L; + ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + + when(productCachePort.getList(brandId, "latest", 0, 20)) + .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(productId), 1))); + when(productCachePort.multiGet(List.of(productId))) + .thenReturn(Map.of(productId, cachedInfo)); + + // act + PageResponse result = productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0).name()).isEqualTo("나이키 에어맥스"); + verify(productRepository, never()).findAllByBrandId(any(), any()); + verify(productRepository, never()).findAllByIdIn(any()); + } + + @DisplayName("목록 캐시 HIT + detail 부분 MISS 시, 미스된 id 만 DB 조회 후 캐시에 저장한다.") + @Test + void fetchesMissingAndPutsCache_whenListCacheHitAndDetailPartialMiss() { + // arrange + Long brandId = 1L; + Long cachedProductId = 10L; + Long missingProductId = 20L; + ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + + Product missingProduct = mock(Product.class); + when(missingProduct.getId()).thenReturn(missingProductId); + when(missingProduct.brandId()).thenReturn(brandId); + when(missingProduct.name()).thenReturn("나이키 줌"); + when(missingProduct.description()).thenReturn("설명"); + when(missingProduct.stock()).thenReturn(Stock.from(5)); + when(missingProduct.price()).thenReturn(Price.from(100000)); + when(missingProduct.likeCount()).thenReturn(0L); + + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + when(productCachePort.getList(brandId, "latest", 0, 20)) + .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(cachedProductId, missingProductId), 1))); + when(productCachePort.multiGet(List.of(cachedProductId, missingProductId))) + .thenReturn(Map.of(cachedProductId, cachedInfo)); + when(productRepository.findAllByIdIn(List.of(missingProductId))).thenReturn(List.of(missingProduct)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + assertThat(result.content()).hasSize(2); + verify(productRepository).findAllByIdIn(List.of(missingProductId)); + verify(productCachePort).put(eq(missingProductId), any(ProductInfo.class)); + } + + @DisplayName("목록 캐시 MISS 시, DB 조회 후 putList 와 put 을 각 상품마다 호출한다.") + @Test + void callsPutListAndPutEach_whenListCacheMiss() { + // arrange + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); + PageRequest pageRequest = PageRequest.of(0, 20, ProductSortType.LATEST.getSort()); + + when(productCachePort.getList(brandId, "latest", 0, 20)).thenReturn(Optional.empty()); + when(productRepository.findAllByBrandId(brandId, pageRequest)).thenReturn(new PageResponse<>(List.of(product), 0, 20, 1)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + verify(productCachePort).putList(eq(brandId), eq("latest"), eq(0), eq(20), eq(List.of(product.getId())), eq(1)); + verify(productCachePort).put(eq(product.getId()), any(ProductInfo.class)); + } + } @DisplayName("상품 상세 조회 시, ") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java index ea65709c3..e49921593 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -26,6 +26,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import com.loopers.support.page.PageResponse; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -60,6 +62,64 @@ void tearDown() { redisCleanUp.truncateAll(); } + @DisplayName("GET /api/v1/products") + @Nested + class GetList { + + @DisplayName("최초 조회 시 (캐시 MISS), 200 응답과 상품 목록을 반환하며 목록 캐시 키가 생성된다.") + @Test + void returnsProductList_whenCacheMiss() { + // arrange + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + productJpaRepository.save( + Product.of("나이키 에어맥스", "편안한 운동화", Stock.from(10), Price.from(150000), brand.getId()) + ); + String url = ENDPOINT + "?brandId=" + brand.getId() + "&sort=latest&page=0&size=20"; + String listCacheKey = "product:list:brandId=" + brand.getId() + ":sort=latest:page=0:size=20"; + + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + + // act + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("나이키 에어맥스"), + () -> assertThat(redisTemplate.hasKey(listCacheKey)).isTrue() + ); + } + + @DisplayName("두 번째 조회 시 (캐시 HIT), 200 응답과 동일한 상품 목록을 반환한다.") + @Test + void returnsSameProductList_whenCacheHit() { + // arrange + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + productJpaRepository.save( + Product.of("나이키 에어맥스", "편안한 운동화", Stock.from(10), Price.from(150000), brand.getId()) + ); + String url = ENDPOINT + "?brandId=" + brand.getId() + "&sort=latest&page=0&size=20"; + String listCacheKey = "product:list:brandId=" + brand.getId() + ":sort=latest:page=0:size=20"; + + ParameterizedTypeReference>> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // act (캐시 HIT) + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, HttpEntity.EMPTY, responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(redisTemplate.hasKey(listCacheKey)).isTrue() + ); + } + + } + @DisplayName("GET /api/v1/products/{productId}") @Nested class GetDetail { From b01b713c8bb586dc14bc879f6931ef75d06ea153 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 13:29:32 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20ProductQueryService=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=BA=90=EC=8B=9C=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 81 +------ .../product/ProductQueryService.java | 112 +++++++++ .../product/ProductFacadeTest.java | 217 ++---------------- .../product/ProductQueryServiceTest.java | 166 ++++++++++++++ 4 files changed, 300 insertions(+), 276 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.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 b8bc89192..779a8e44f 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 @@ -5,23 +5,17 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.support.page.PageResponse; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; @RequiredArgsConstructor @Component @@ -31,7 +25,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final LikeRepository likeRepository; private final ProductAssembler productAssembler; - private final ProductCacheStore productCacheStore; + private final ProductQueryService productQueryService; @Transactional public void register(String name, String description, Integer stock, Integer price, Long brandId) { @@ -54,7 +48,7 @@ public void update(Long productId, String name, String description, Integer stoc Price priceVo = Price.from(price); product.update(name, description, stockVo, priceVo); - productCacheStore.evict(productId); + productQueryService.evict(productId); } @Transactional(readOnly = true) @@ -72,79 +66,12 @@ public PageResponse getList(Pageable pageable) { @Transactional(readOnly = true) public PageResponse getList(Pageable pageable, Long brandId, String sort) { - int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); - PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort()); - - Optional listCache = productCacheStore.getList(brandId, sort, page, size); - if (listCache.isPresent()) { - List productIds = listCache.get().productIds(); - int totalPages = listCache.get().totalPages(); - if (productIds.isEmpty()) { - return new PageResponse<>(List.of(), page, size, totalPages); - } - Map cachedInfos = productCacheStore.multiGet(productIds); - List missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); - if (missingIds.isEmpty()) { - return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); - } - Map allInfoMap = new HashMap<>(cachedInfos); - List missingProducts = productRepository.findAllByIdIn(missingIds); - List missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); - List missingBrands = brandRepository.findAllByIdIn(missingBrandIds); - List missingInfos = productAssembler.toInfos(missingProducts, missingBrands); - for (int i = 0; i < missingProducts.size(); i++) { - Long productId = missingProducts.get(i).getId(); - ProductInfo info = missingInfos.get(i); - allInfoMap.put(productId, info); - productCacheStore.put(productId, info); - } - List orderedInfos = productIds.stream() - .map(allInfoMap::get) - .filter(Objects::nonNull) - .toList(); - return new PageResponse<>(orderedInfos, page, size, totalPages); - } - - PageResponse products = brandId == null - ? productRepository.findAll(pageRequest) - : productRepository.findAllByBrandId(brandId, pageRequest); - - List productList = products.content(); - if (productList.isEmpty()) { - productCacheStore.putList(brandId, sort, page, size, List.of(), products.totalPages()); - return new PageResponse<>(List.of(), products.page(), products.size(), products.totalPages()); - } - - List brandIds = productList.stream().map(Product::brandId).toList(); - List brands = brandRepository.findAllByIdIn(brandIds); - List productInfos = productAssembler.toInfos(productList, brands); - - List productIds = productList.stream().map(Product::getId).toList(); - productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); - for (int i = 0; i < productList.size(); i++) { - productCacheStore.put(productList.get(i).getId(), productInfos.get(i)); - } - - return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); + return productQueryService.getList(pageable, brandId, sort); } @Transactional(readOnly = true) public ProductInfo getDetail(Long productId) { - Optional cached = productCacheStore.get(productId); - if (cached.isPresent()) { - return cached.get(); - } - - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); - - Optional optionalBrand = brandRepository.findById(product.brandId()); - ProductInfo productInfo = ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); - - productCacheStore.put(productId, productInfo); - - return productInfo; + return productQueryService.getDetail(productId); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java new file mode 100644 index 000000000..631070f30 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -0,0 +1,112 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.support.page.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductQueryService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final ProductAssembler productAssembler; + private final ProductCacheStore productCacheStore; + + @Transactional(readOnly = true) + public PageResponse getList(Pageable pageable, Long brandId, String sort) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort()); + + Optional listCache = productCacheStore.getList(brandId, sort, page, size); + if (listCache.isPresent()) { + List productIds = listCache.get().productIds(); + int totalPages = listCache.get().totalPages(); + if (productIds.isEmpty()) { + return new PageResponse<>(List.of(), page, size, totalPages); + } + Map cachedInfos = productCacheStore.multiGet(productIds); + List missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); + if (missingIds.isEmpty()) { + return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); + } + Map allInfoMap = new HashMap<>(cachedInfos); + List missingProducts = productRepository.findAllByIdIn(missingIds); + List missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); + List missingBrands = brandRepository.findAllByIdIn(missingBrandIds); + List missingInfos = productAssembler.toInfos(missingProducts, missingBrands); + for (int i = 0; i < missingProducts.size(); i++) { + Long productId = missingProducts.get(i).getId(); + ProductInfo info = missingInfos.get(i); + allInfoMap.put(productId, info); + productCacheStore.put(productId, info); + } + List orderedInfos = productIds.stream() + .map(allInfoMap::get) + .filter(Objects::nonNull) + .toList(); + return new PageResponse<>(orderedInfos, page, size, totalPages); + } + + PageResponse products = brandId == null + ? productRepository.findAll(pageRequest) + : productRepository.findAllByBrandId(brandId, pageRequest); + + List productList = products.content(); + if (productList.isEmpty()) { + productCacheStore.putList(brandId, sort, page, size, List.of(), products.totalPages()); + return new PageResponse<>(List.of(), products.page(), products.size(), products.totalPages()); + } + + List brandIds = productList.stream().map(Product::brandId).toList(); + List brands = brandRepository.findAllByIdIn(brandIds); + List productInfos = productAssembler.toInfos(productList, brands); + + List productIds = productList.stream().map(Product::getId).toList(); + productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); + for (int i = 0; i < productList.size(); i++) { + productCacheStore.put(productList.get(i).getId(), productInfos.get(i)); + } + + return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); + } + + @Transactional(readOnly = true) + public ProductInfo getDetail(Long productId) { + Optional cached = productCacheStore.get(productId); + if (cached.isPresent()) { + return cached.get(); + } + + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + + Optional optionalBrand = brandRepository.findById(product.brandId()); + ProductInfo productInfo = ProductInfo.of(product, optionalBrand.map(Brand::name).orElse(null)); + + productCacheStore.put(productId, productInfo); + + return productInfo; + } + + public void evict(Long productId) { + productCacheStore.evict(productId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index ef29b8c1c..468f50791 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -5,7 +5,6 @@ import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.product.ProductSortType; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; import com.loopers.support.error.CoreException; @@ -16,7 +15,6 @@ import org.springframework.data.domain.PageRequest; import java.util.List; -import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -24,7 +22,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,8 +31,8 @@ class ProductFacadeTest { ProductRepository productRepository = mock(ProductRepository.class); LikeRepository likeRepository = mock(LikeRepository.class); ProductAssembler productAssembler = new ProductAssembler(); - ProductCacheStore productCachePort = mock(ProductCacheStore.class); - ProductFacade productFacade = new ProductFacade(brandRepository, productRepository, likeRepository, productAssembler, productCachePort); + ProductQueryService productQueryService = mock(ProductQueryService.class); + ProductFacade productFacade = new ProductFacade(brandRepository, productRepository, likeRepository, productAssembler, productQueryService); @DisplayName("상품 등록 시, ") @Nested @@ -121,37 +118,6 @@ void returnsProductInfoWithNullBrand_whenBrandNotFound() { assertThat(result.content().get(0).brand()).isNull(); } - @DisplayName("좋아요 순 정렬 시, likeCount 내림차순으로 정렬된 상품 목록이 반환된다.") - @Test - void returnsProductsSortedByLikeCountDesc_whenSortIsLikeCount() { - // arrange - Long brandId = 1L; - Brand brand = mock(Brand.class); - when(brand.getId()).thenReturn(brandId); - when(brand.name()).thenReturn("나이키"); - - Product productWithMoreLikes = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); - productWithMoreLikes.increaseLike(); - productWithMoreLikes.increaseLike(); - - Product productWithLessLikes = Product.of("나이키 줌", "설명", Stock.from(5), Price.from(100000), brandId); - productWithLessLikes.increaseLike(); - - PageRequest pageRequest = PageRequest.of(0, 20, ProductSortType.LIKE_COUNT.getSort()); - when(productRepository.findAll(pageRequest)).thenReturn( - new PageResponse<>(List.of(productWithMoreLikes, productWithLessLikes), 0, 20, 1) - ); - when(brandRepository.findAllByIdIn(List.of(brandId, brandId))).thenReturn(List.of(brand)); - - // act - PageResponse result = productFacade.getList(PageRequest.of(0, 20), null, "like_count"); - - // assert - assertThat(result.content()).hasSize(2); - assertThat(result.content().get(0).likeCount()).isEqualTo(2L); - assertThat(result.content().get(1).likeCount()).isEqualTo(1L); - } - @DisplayName("등록된 상품이 없으면, 빈 목록을 반환한다.") @Test void returnsEmptyList_whenNoProducts() { @@ -166,189 +132,42 @@ void returnsEmptyList_whenNoProducts() { assertThat(result.content()).isEmpty(); } - @DisplayName("목록 캐시 HIT + detail 전체 HIT 시, DB 조회 없이 캐시된 상품 목록을 반환한다.") - @Test - void returnsCachedList_whenListCacheHitAndAllDetailCacheHit() { - // arrange - Long brandId = 1L; - Long productId = 10L; - ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); - - when(productCachePort.getList(brandId, "latest", 0, 20)) - .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(productId), 1))); - when(productCachePort.multiGet(List.of(productId))) - .thenReturn(Map.of(productId, cachedInfo)); - - // act - PageResponse result = productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); - - // assert - assertThat(result.content()).hasSize(1); - assertThat(result.content().get(0).name()).isEqualTo("나이키 에어맥스"); - verify(productRepository, never()).findAllByBrandId(any(), any()); - verify(productRepository, never()).findAllByIdIn(any()); - } - - @DisplayName("목록 캐시 HIT + detail 부분 MISS 시, 미스된 id 만 DB 조회 후 캐시에 저장한다.") + @DisplayName("브랜드 필터 + 정렬 조회 시, ProductQueryService 에 위임한다.") @Test - void fetchesMissingAndPutsCache_whenListCacheHitAndDetailPartialMiss() { + void delegatesToProductQueryService_whenBrandIdAndSortGiven() { // arrange Long brandId = 1L; - Long cachedProductId = 10L; - Long missingProductId = 20L; - ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); - - Product missingProduct = mock(Product.class); - when(missingProduct.getId()).thenReturn(missingProductId); - when(missingProduct.brandId()).thenReturn(brandId); - when(missingProduct.name()).thenReturn("나이키 줌"); - when(missingProduct.description()).thenReturn("설명"); - when(missingProduct.stock()).thenReturn(Stock.from(5)); - when(missingProduct.price()).thenReturn(Price.from(100000)); - when(missingProduct.likeCount()).thenReturn(0L); - - Brand brand = mock(Brand.class); - when(brand.getId()).thenReturn(brandId); - when(brand.name()).thenReturn("나이키"); - - when(productCachePort.getList(brandId, "latest", 0, 20)) - .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(cachedProductId, missingProductId), 1))); - when(productCachePort.multiGet(List.of(cachedProductId, missingProductId))) - .thenReturn(Map.of(cachedProductId, cachedInfo)); - when(productRepository.findAllByIdIn(List.of(missingProductId))).thenReturn(List.of(missingProduct)); - when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + PageRequest pageable = PageRequest.of(0, 20); + PageResponse expected = new PageResponse<>(List.of(), 0, 20, 0); + when(productQueryService.getList(pageable, brandId, "latest")).thenReturn(expected); // act - PageResponse result = productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); + PageResponse result = productFacade.getList(pageable, brandId, "latest"); // assert - assertThat(result.content()).hasSize(2); - verify(productRepository).findAllByIdIn(List.of(missingProductId)); - verify(productCachePort).put(eq(missingProductId), any(ProductInfo.class)); + verify(productQueryService).getList(eq(pageable), eq(brandId), eq("latest")); + assertThat(result).isEqualTo(expected); } - - @DisplayName("목록 캐시 MISS 시, DB 조회 후 putList 와 put 을 각 상품마다 호출한다.") - @Test - void callsPutListAndPutEach_whenListCacheMiss() { - // arrange - Long brandId = 1L; - Brand brand = mock(Brand.class); - when(brand.getId()).thenReturn(brandId); - when(brand.name()).thenReturn("나이키"); - - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); - PageRequest pageRequest = PageRequest.of(0, 20, ProductSortType.LATEST.getSort()); - - when(productCachePort.getList(brandId, "latest", 0, 20)).thenReturn(Optional.empty()); - when(productRepository.findAllByBrandId(brandId, pageRequest)).thenReturn(new PageResponse<>(List.of(product), 0, 20, 1)); - when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); - - // act - productFacade.getList(PageRequest.of(0, 20), brandId, "latest"); - - // assert - verify(productCachePort).putList(eq(brandId), eq("latest"), eq(0), eq(20), eq(List.of(product.getId())), eq(1)); - verify(productCachePort).put(eq(product.getId()), any(ProductInfo.class)); - } - } @DisplayName("상품 상세 조회 시, ") @Nested class GetDetail { - @DisplayName("존재하는 상품이면, 브랜드명과 좋아요 수를 포함한 상품 정보를 반환한다.") - @Test - void returnsProductInfo_whenProductExists() { - // arrange - Long productId = 1L; - Long brandId = 1L; - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); - Brand brand = Brand.of("나이키", null); - - when(productCachePort.get(productId)).thenReturn(Optional.empty()); - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - - // act - ProductInfo result = productFacade.getDetail(productId); - - // assert - assertThat(result.name()).isEqualTo("나이키 에어맥스"); - assertThat(result.brand()).isEqualTo("나이키"); - assertThat(result.likeCount()).isEqualTo(0L); - } - - @DisplayName("브랜드가 존재하지 않는 상품이면, 브랜드명이 null인 상품 정보를 반환한다.") - @Test - void returnsProductInfoWithNullBrand_whenBrandNotFound() { - // arrange - Long productId = 1L; - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), 999L); - - when(productCachePort.get(productId)).thenReturn(Optional.empty()); - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(brandRepository.findById(999L)).thenReturn(Optional.empty()); - - // act - ProductInfo result = productFacade.getDetail(productId); - - // assert - assertThat(result.brand()).isNull(); - } - - @DisplayName("존재하지 않는 상품이면, CoreException 이 발생한다.") - @Test - void throwsCoreException_whenProductNotFound() { - // arrange - Long productId = 999L; - when(productCachePort.get(productId)).thenReturn(Optional.empty()); - when(productRepository.findById(productId)).thenReturn(Optional.empty()); - - // act - CoreException result = assertThrows(CoreException.class, () -> - productFacade.getDetail(productId) - ); - - // assert - assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); - } - - @DisplayName("캐시 HIT 시, DB 조회 없이 캐시된 ProductInfo 를 반환한다.") + @DisplayName("존재하는 상품이면, ProductQueryService 에 위임한다.") @Test - void returnsCachedProductInfo_whenCacheHit() { + void delegatesToProductQueryService_whenProductExists() { // arrange Long productId = 1L; - ProductInfo cached = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); - when(productCachePort.get(productId)).thenReturn(Optional.of(cached)); + ProductInfo expected = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + when(productQueryService.getDetail(productId)).thenReturn(expected); // act ProductInfo result = productFacade.getDetail(productId); // assert - assertThat(result.name()).isEqualTo("나이키 에어맥스"); - assertThat(result.brand()).isEqualTo("나이키"); - verify(productRepository, never()).findById(productId); - } - - @DisplayName("캐시 MISS 시, DB 에서 조회 후 캐시에 저장한다.") - @Test - void savesToCache_whenCacheMiss() { - // arrange - Long productId = 1L; - Long brandId = 1L; - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); - Brand brand = Brand.of("나이키", null); - - when(productCachePort.get(productId)).thenReturn(Optional.empty()); - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); - - // act - productFacade.getDetail(productId); - - // assert - verify(productCachePort).put(eq(productId), any(ProductInfo.class)); + verify(productQueryService).getDetail(productId); + assertThat(result).isEqualTo(expected); } } @@ -387,7 +206,7 @@ void throwsCoreException_whenProductNotFound() { assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); } - @DisplayName("상품 수정 시, 캐시가 삭제된다.") + @DisplayName("상품 수정 시, ProductQueryService.evict 를 호출한다.") @Test void evictsCache_whenProductUpdated() { // arrange @@ -399,7 +218,7 @@ void evictsCache_whenProductUpdated() { productFacade.update(productId, "나이키 줌", "새 설명", 20, 200000); // assert - verify(productCachePort).evict(productId); + verify(productQueryService).evict(productId); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java new file mode 100644 index 000000000..116488bb8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java @@ -0,0 +1,166 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.vo.Price; +import com.loopers.domain.product.vo.Stock; +import com.loopers.support.page.PageResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProductQueryServiceTest { + + BrandRepository brandRepository = mock(BrandRepository.class); + ProductRepository productRepository = mock(ProductRepository.class); + ProductAssembler productAssembler = new ProductAssembler(); + ProductCacheStore productCacheStore = mock(ProductCacheStore.class); + ProductQueryService productQueryService = new ProductQueryService(productRepository, brandRepository, productAssembler, productCacheStore); + + @DisplayName("상품 목록 조회 시, ") + @Nested + class GetList { + + @DisplayName("목록 캐시 HIT + detail 전체 HIT 시, DB 조회 없이 캐시된 상품 목록을 반환한다.") + @Test + void returnsCachedList_whenListCacheHitAndAllDetailCacheHit() { + // arrange + Long brandId = 1L; + Long productId = 10L; + ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + + when(productCacheStore.getList(brandId, "latest", 0, 20)) + .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(productId), 1))); + when(productCacheStore.multiGet(List.of(productId))) + .thenReturn(Map.of(productId, cachedInfo)); + + // act + PageResponse result = productQueryService.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0).name()).isEqualTo("나이키 에어맥스"); + verify(productRepository, never()).findAllByBrandId(any(), any()); + verify(productRepository, never()).findAllByIdIn(any()); + } + + @DisplayName("목록 캐시 HIT + detail 부분 MISS 시, 미스된 id 만 DB 조회 후 캐시에 저장한다.") + @Test + void fetchesMissingAndPutsCache_whenListCacheHitAndDetailPartialMiss() { + // arrange + Long brandId = 1L; + Long cachedProductId = 10L; + Long missingProductId = 20L; + ProductInfo cachedInfo = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + + Product missingProduct = mock(Product.class); + when(missingProduct.getId()).thenReturn(missingProductId); + when(missingProduct.brandId()).thenReturn(brandId); + when(missingProduct.name()).thenReturn("나이키 줌"); + when(missingProduct.description()).thenReturn("설명"); + when(missingProduct.stock()).thenReturn(Stock.from(5)); + when(missingProduct.price()).thenReturn(Price.from(100000)); + when(missingProduct.likeCount()).thenReturn(0L); + + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + when(productCacheStore.getList(brandId, "latest", 0, 20)) + .thenReturn(Optional.of(new ProductCacheStore.ProductListCacheEntry(List.of(cachedProductId, missingProductId), 1))); + when(productCacheStore.multiGet(List.of(cachedProductId, missingProductId))) + .thenReturn(Map.of(cachedProductId, cachedInfo)); + when(productRepository.findAllByIdIn(List.of(missingProductId))).thenReturn(List.of(missingProduct)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = productQueryService.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + assertThat(result.content()).hasSize(2); + verify(productRepository).findAllByIdIn(List.of(missingProductId)); + verify(productCacheStore).put(eq(missingProductId), any(ProductInfo.class)); + } + + @DisplayName("목록 캐시 MISS 시, DB 조회 후 putList 와 put 을 각 상품마다 호출한다.") + @Test + void callsPutListAndPutEach_whenListCacheMiss() { + // arrange + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); + PageRequest pageRequest = PageRequest.of(0, 20, com.loopers.domain.product.ProductSortType.LATEST.getSort()); + + when(productCacheStore.getList(brandId, "latest", 0, 20)).thenReturn(Optional.empty()); + when(productRepository.findAllByBrandId(brandId, pageRequest)).thenReturn(new PageResponse<>(List.of(product), 0, 20, 1)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + productQueryService.getList(PageRequest.of(0, 20), brandId, "latest"); + + // assert + verify(productCacheStore).putList(eq(brandId), eq("latest"), eq(0), eq(20), eq(List.of(product.getId())), eq(1)); + verify(productCacheStore).put(eq(product.getId()), any(ProductInfo.class)); + } + } + + @DisplayName("상품 상세 조회 시, ") + @Nested + class GetDetail { + + @DisplayName("캐시 HIT 시, DB 조회 없이 캐시된 ProductInfo 를 반환한다.") + @Test + void returnsCachedProductInfo_whenCacheHit() { + // arrange + Long productId = 1L; + ProductInfo cached = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + when(productCacheStore.get(productId)).thenReturn(Optional.of(cached)); + + // act + ProductInfo result = productQueryService.getDetail(productId); + + // assert + assertThat(result.name()).isEqualTo("나이키 에어맥스"); + assertThat(result.brand()).isEqualTo("나이키"); + verify(productRepository, never()).findById(productId); + } + + @DisplayName("캐시 MISS 시, DB 에서 조회 후 캐시에 저장한다.") + @Test + void savesToCache_whenCacheMiss() { + // arrange + Long productId = 1L; + Long brandId = 1L; + Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); + Brand brand = Brand.of("나이키", null); + + when(productCacheStore.get(productId)).thenReturn(Optional.empty()); + when(productRepository.findById(productId)).thenReturn(Optional.of(product)); + when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); + + // act + productQueryService.getDetail(productId); + + // assert + verify(productCacheStore).put(eq(productId), any(ProductInfo.class)); + } + } +} From dfccc6cbc5566cd0b24e885602caa0da42209520 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 14:19:02 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20toInfoMap=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20index=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20getList=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductAssembler.toInfos → toInfoMap 으로 변경 (반환 타입 List → Map) - ProductQueryService.getList 를 resolveFromCache / resolveFromDatabase 로 분리 - partial cache HIT 처리 시 index 기반 for loop 제거, Map 기반 putAll/forEach 로 대체 - ProductFacade 도 toInfoMap 사용으로 통일 Co-Authored-By: Claude Sonnet 4.6 --- .../application/product/ProductAssembler.java | 13 ++-- .../application/product/ProductFacade.java | 5 +- .../product/ProductQueryService.java | 70 ++++++++++--------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAssembler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAssembler.java index 4ba7ae054..19cdae7af 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAssembler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductAssembler.java @@ -11,13 +11,16 @@ @Component public class ProductAssembler { - public List toInfos(List products, List brands) { + public Map toInfoMap(List products, List brands) { Map brandMap = brands.stream().collect(Collectors.toMap(Brand::getId, b -> b)); return products.stream() - .map(product -> { - Brand brand = brandMap.get(product.brandId()); - return ProductInfo.of(product, brand != null ? brand.name() : null); - }).toList(); + .collect(Collectors.toMap( + Product::getId, + product -> { + Brand brand = brandMap.get(product.brandId()); + return ProductInfo.of(product, brand != null ? brand.name() : null); + } + )); } } 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 779a8e44f..2dfb807ed 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 @@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Objects; @RequiredArgsConstructor @Component @@ -60,7 +62,8 @@ public PageResponse getList(Pageable pageable) { List brandIds = productList.stream().map(Product::brandId).toList(); List brands = brandRepository.findAllByIdIn(brandIds); - List productInfos = productAssembler.toInfos(productList, brands); + Map infoMap = productAssembler.toInfoMap(productList, brands); + List productInfos = productList.stream().map(p -> infoMap.get(p.getId())).filter(Objects::nonNull).toList(); return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index 631070f30..92cb17b96 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -35,36 +35,41 @@ public PageResponse getList(Pageable pageable, Long brandId, String int size = pageable.getPageSize(); PageRequest pageRequest = PageRequest.of(page, size, ProductSortType.from(sort).getSort()); - Optional listCache = productCacheStore.getList(brandId, sort, page, size); - if (listCache.isPresent()) { - List productIds = listCache.get().productIds(); - int totalPages = listCache.get().totalPages(); - if (productIds.isEmpty()) { - return new PageResponse<>(List.of(), page, size, totalPages); - } - Map cachedInfos = productCacheStore.multiGet(productIds); - List missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); - if (missingIds.isEmpty()) { - return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); - } - Map allInfoMap = new HashMap<>(cachedInfos); - List missingProducts = productRepository.findAllByIdIn(missingIds); - List missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); - List missingBrands = brandRepository.findAllByIdIn(missingBrandIds); - List missingInfos = productAssembler.toInfos(missingProducts, missingBrands); - for (int i = 0; i < missingProducts.size(); i++) { - Long productId = missingProducts.get(i).getId(); - ProductInfo info = missingInfos.get(i); - allInfoMap.put(productId, info); - productCacheStore.put(productId, info); - } - List orderedInfos = productIds.stream() - .map(allInfoMap::get) - .filter(Objects::nonNull) - .toList(); - return new PageResponse<>(orderedInfos, page, size, totalPages); + return productCacheStore.getList(brandId, sort, page, size) + .map(entry -> resolveFromCache(entry, page, size)) + .orElseGet(() -> resolveFromDatabase(brandId, pageRequest, sort, page, size)); + } + + private PageResponse resolveFromCache(ProductCacheStore.ProductListCacheEntry entry, int page, int size) { + List productIds = entry.productIds(); + int totalPages = entry.totalPages(); + if (productIds.isEmpty()) { + return new PageResponse<>(List.of(), page, size, totalPages); } + Map cachedInfos = productCacheStore.multiGet(productIds); + List missingIds = productIds.stream().filter(id -> !cachedInfos.containsKey(id)).toList(); + if (missingIds.isEmpty()) { + return new PageResponse<>(productIds.stream().map(cachedInfos::get).toList(), page, size, totalPages); + } + + Map allInfoMap = new HashMap<>(cachedInfos); + List missingProducts = productRepository.findAllByIdIn(missingIds); + List missingBrandIds = missingProducts.stream().map(Product::brandId).distinct().toList(); + List missingBrands = brandRepository.findAllByIdIn(missingBrandIds); + Map missingInfoMap = productAssembler.toInfoMap(missingProducts, missingBrands); + allInfoMap.putAll(missingInfoMap); + missingInfoMap.forEach(productCacheStore::put); + + List orderedInfos = productIds.stream() + .map(allInfoMap::get) + .filter(Objects::nonNull) + .toList(); + + return new PageResponse<>(orderedInfos, page, size, totalPages); + } + + private PageResponse resolveFromDatabase(Long brandId, PageRequest pageRequest, String sort, int page, int size) { PageResponse products = brandId == null ? productRepository.findAll(pageRequest) : productRepository.findAllByBrandId(brandId, pageRequest); @@ -77,15 +82,14 @@ public PageResponse getList(Pageable pageable, Long brandId, String List brandIds = productList.stream().map(Product::brandId).toList(); List brands = brandRepository.findAllByIdIn(brandIds); - List productInfos = productAssembler.toInfos(productList, brands); + Map infoMap = productAssembler.toInfoMap(productList, brands); List productIds = productList.stream().map(Product::getId).toList(); productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); - for (int i = 0; i < productList.size(); i++) { - productCacheStore.put(productList.get(i).getId(), productInfos.get(i)); - } + infoMap.forEach((id, info) -> productCacheStore.put(id, info)); - return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); + List orderedInfos = productIds.stream().map(infoMap::get).filter(Objects::nonNull).toList(); + return new PageResponse<>(orderedInfos, products.page(), products.size(), products.totalPages()); } @Transactional(readOnly = true) From 5524817852869c6744b6e2e7bc95ff4da5e84d80 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 14:26:24 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20TTL=20=EC=83=81=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20ProductCacheStore=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시 유효 기간은 application layer의 정책이므로 RedisProductCacheStore 에서 정의하던 TTL 상수를 ProductCacheStore 인터페이스로 이동. 키 포맷은 Redis 구현 세부사항으로 infrastructure에 유지. Co-Authored-By: Claude Sonnet 4.6 --- .../com/loopers/application/product/ProductCacheStore.java | 3 +++ .../loopers/infrastructure/product/RedisProductCacheStore.java | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index 22224a665..6a1b544ca 100644 --- 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 @@ -6,6 +6,9 @@ public interface ProductCacheStore { + long DETAIL_TTL_MINUTES = 5; + long LIST_TTL_SECONDS = 30; + Optional get(Long productId); void put(Long productId, ProductInfo productInfo); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java index f746b2d27..9241538fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java @@ -19,8 +19,6 @@ public class RedisProductCacheStore implements ProductCacheStore { private static final String DETAIL_KEY_PREFIX = "product:detail:"; private static final String LIST_KEY_PREFIX = "product:list:"; - private static final long DETAIL_TTL_MINUTES = 5; - private static final long LIST_TTL_SECONDS = 30; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; From 412cce7a10a28bc0ea41653c53df945f87f381ce Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 13 Mar 2026 14:28:36 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C=20evict=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/loopers/application/product/ProductFacade.java | 1 + 1 file changed, 1 insertion(+) 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 2dfb807ed..9579a7d5e 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 @@ -81,5 +81,6 @@ public ProductInfo getDetail(Long productId) { public void delete(Long productId) { likeRepository.deleteAllByProductId(productId); productRepository.deleteById(productId); + productQueryService.evict(productId); } }