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/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java new file mode 100644 index 000000000..6a1b544ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java @@ -0,0 +1,25 @@ +package com.loopers.application.product; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface ProductCacheStore { + + long DETAIL_TTL_MINUTES = 5; + long LIST_TTL_SECONDS = 30; + + Optional get(Long productId); + + 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 dda5770ec..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 @@ -5,20 +5,19 @@ 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.List; -import java.util.Optional; +import java.util.Map; +import java.util.Objects; @RequiredArgsConstructor @Component @@ -28,6 +27,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final LikeRepository likeRepository; private final ProductAssembler productAssembler; + private final ProductQueryService productQueryService; @Transactional public void register(String name, String description, Integer stock, Integer price, Long brandId) { @@ -49,6 +49,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); + + productQueryService.evict(productId); } @Transactional(readOnly = true) @@ -60,42 +62,25 @@ 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()); } @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); - } - - List productList = products.content(); - if (productList.isEmpty()) 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); - return new PageResponse<>(productInfos, products.page(), products.size(), products.totalPages()); + return productQueryService.getList(pageable, brandId, sort); } @Transactional(readOnly = true) public ProductInfo getDetail(Long productId) { - 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)); + return productQueryService.getDetail(productId); } @Transactional public void delete(Long productId) { likeRepository.deleteAllByProductId(productId); productRepository.deleteById(productId); + productQueryService.evict(productId); } } 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..92cb17b96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -0,0 +1,116 @@ +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()); + + 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); + + 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); + Map infoMap = productAssembler.toInfoMap(productList, brands); + + List productIds = productList.stream().map(Product::getId).toList(); + productCacheStore.putList(brandId, sort, page, size, productIds, products.totalPages()); + infoMap.forEach((id, info) -> productCacheStore.put(id, info)); + + List orderedInfos = productIds.stream().map(infoMap::get).filter(Objects::nonNull).toList(); + return new PageResponse<>(orderedInfos, 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/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/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..9241538fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheStore.java @@ -0,0 +1,96 @@ +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +@Component +public class RedisProductCacheStore implements ProductCacheStore { + + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String LIST_KEY_PREFIX = "product:list:"; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(Long productId) { + try { + String cached = redisTemplate.opsForValue().get(DETAIL_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(DETAIL_KEY_PREFIX + productId, json, DETAIL_TTL_MINUTES, TimeUnit.MINUTES); + } catch (Exception ignored) { + } + } + + @Override + public void evict(Long 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 de1c9b699..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 @@ -31,7 +31,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); + ProductQueryService productQueryService = mock(ProductQueryService.class); + ProductFacade productFacade = new ProductFacade(brandRepository, productRepository, likeRepository, productAssembler, productQueryService); @DisplayName("상품 등록 시, ") @Nested @@ -130,64 +131,43 @@ void returnsEmptyList_whenNoProducts() { // assert assertThat(result.content()).isEmpty(); } - } - - @DisplayName("상품 상세 조회 시, ") - @Nested - class GetDetail { - @DisplayName("존재하는 상품이면, 브랜드명과 좋아요 수를 포함한 상품 정보를 반환한다.") + @DisplayName("브랜드 필터 + 정렬 조회 시, ProductQueryService 에 위임한다.") @Test - void returnsProductInfo_whenProductExists() { + void delegatesToProductQueryService_whenBrandIdAndSortGiven() { // arrange - Long productId = 1L; Long brandId = 1L; - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), brandId); - Brand brand = Brand.of("나이키", null); - - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(brandRepository.findById(brandId)).thenReturn(Optional.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 - ProductInfo result = productFacade.getDetail(productId); + PageResponse result = productFacade.getList(pageable, brandId, "latest"); // assert - assertThat(result.name()).isEqualTo("나이키 에어맥스"); - assertThat(result.brand()).isEqualTo("나이키"); - assertThat(result.likeCount()).isEqualTo(0L); + verify(productQueryService).getList(eq(pageable), eq(brandId), eq("latest")); + assertThat(result).isEqualTo(expected); } + } - @DisplayName("브랜드가 존재하지 않는 상품이면, 브랜드명이 null인 상품 정보를 반환한다.") + @DisplayName("상품 상세 조회 시, ") + @Nested + class GetDetail { + + @DisplayName("존재하는 상품이면, ProductQueryService 에 위임한다.") @Test - void returnsProductInfoWithNullBrand_whenBrandNotFound() { + void delegatesToProductQueryService_whenProductExists() { // arrange Long productId = 1L; - Product product = Product.of("나이키 에어맥스", "설명", Stock.from(10), Price.from(150000), 999L); - - when(productRepository.findById(productId)).thenReturn(Optional.of(product)); - when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + ProductInfo expected = new ProductInfo("나이키 에어맥스", "설명", 10, 150000, "나이키", 0L); + when(productQueryService.getDetail(productId)).thenReturn(expected); // act ProductInfo result = productFacade.getDetail(productId); // assert - assertThat(result.brand()).isNull(); - } - - @DisplayName("존재하지 않는 상품이면, CoreException 이 발생한다.") - @Test - void throwsCoreException_whenProductNotFound() { - // arrange - Long productId = 999L; - when(productRepository.findById(productId)).thenReturn(Optional.empty()); - - // act - CoreException result = assertThrows(CoreException.class, () -> - productFacade.getDetail(productId) - ); - - // assert - assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); + verify(productQueryService).getDetail(productId); + assertThat(result).isEqualTo(expected); } } @@ -225,6 +205,21 @@ void throwsCoreException_whenProductNotFound() { // assert assertThat(result.getCustomMessage()).isEqualTo("존재하지 않는 상품입니다."); } + + @DisplayName("상품 수정 시, ProductQueryService.evict 를 호출한다.") + @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(productQueryService).evict(productId); + } } @DisplayName("상품 삭제 시, ") 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)); + } + } +} 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..e49921593 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -0,0 +1,220 @@ +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 com.loopers.support.page.PageResponse; + +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") + @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 { + + @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); + } + } +}