From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 9205becefe8497843c7bbdbcd218f7f0c9c98820 Mon Sep 17 00:00:00 2001 From: dfdf0202 Date: Fri, 13 Mar 2026 11:49:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat=20:=205=EC=A3=BC=EC=B0=A8=20=EA=B3=BC?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/favorite/FavoriteFacade.java | 6 +- .../application/product/ProductFacade.java | 38 +- .../product/dto/FindProductListReqDto.java | 11 + .../product/dto/FindProductResDto.java | 23 +- .../repository/FavoriteRepository.java | 3 + .../favorite/service/FavoriteService.java | 20 +- .../loopers/domain/product/model/Product.java | 5 +- .../domain/product/model/ProductItem.java | 10 +- .../repository/ProductCacheRepository.java | 31 ++ .../repository/ProductCustomRepository.java | 5 +- .../product/repository/ProductRepository.java | 4 + .../product/service/ProductService.java | 66 ++- .../favorite/entity/FavoriteEntity.java | 5 +- .../repository/FavoriteJpaRepository.java | 3 + .../impl/FavoriteRepositoryImpl.java | 9 + .../product/cache/ProductCacheDto.java | 12 + .../cache/ProductCacheRepositoryImpl.java | 93 +++++ .../product/cache/ProductListCacheDto.java | 9 + .../product/entity/ProductEntity.java | 15 +- .../entity/ProductLikeStatsEntity.java | 29 ++ .../repository/ProductJpaRepository.java | 8 + .../ProductLikeStatsJpaRepository.java | 18 + .../impl/ProductCustomRepositoryImpl.java | 36 +- .../impl/ProductRepositoryImpl.java | 10 + .../api/product/ProductV1Controller.java | 4 +- .../support/cache/RedisCacheManager.java | 98 +++++ .../com/loopers/support/enums/SortFilter.java | 6 +- .../product/ProductIndexPerformanceTest.java | 394 ++++++++++++++++++ .../product/ProductQueryPerformanceTest.java | 212 ++++++++++ .../support/cache/RedisCacheManagerTest.java | 157 +++++++ 30 files changed, 1261 insertions(+), 79 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductListCacheDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java index 36539d30d..fec6bb36f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/favorite/FavoriteFacade.java @@ -24,7 +24,10 @@ public void addFavorite(String loginId, String password, Long productId) { Member member = memberService.findMember(loginId, password); Product product = productService.findProduct(productId); FavoriteCommand.Add command = new FavoriteCommand.Add(member.getId(), product.getId()); - favoriteService.addFavorite(command); + boolean added = favoriteService.addFavorite(command); + if (added) { + productService.increaseLikeCount(product.getId()); + } } @Transactional(rollbackFor = {Exception.class}) @@ -33,5 +36,6 @@ public void deleteFavorite(String loginId, String password, Long productId) { Product product = productService.findProduct(productId); FavoriteCommand.Delete command = new FavoriteCommand.Delete(member.getId(), product.getId()); favoriteService.delete(command); + productService.decreaseLikeCount(product.getId()); } } 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 d5e8326e1..9596c75e0 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 @@ -1,22 +1,23 @@ package com.loopers.application.product; +import com.loopers.application.product.dto.FindProductListReqDto; import com.loopers.application.product.dto.FindProductListResDto; import com.loopers.application.product.dto.FindProductResDto; -import com.loopers.domain.brand.model.Brand; import com.loopers.domain.brand.service.BrandService; import com.loopers.domain.favorite.service.FavoriteService; import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; -import com.loopers.domain.product.model.Product; import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.service.ProductService; -import com.loopers.support.enums.SortFilter; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Set; + @RequiredArgsConstructor @Component @Transactional(readOnly = true) @@ -24,27 +25,30 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; - private final FavoriteService favoriteService; private final MemberService memberService; + private final FavoriteService favoriteService; - public Page findProductList(String loginId, String password, Long brandId, SortFilter sortFilter, Pageable pageable) { - if (brandId != null) { - brandService.findBrand(brandId); + public Page findProductList(FindProductListReqDto req, Pageable pageable) { + if (req.brandId() != null) { + brandService.findBrand(req.brandId()); } - Long memberId = resolveMemberId(loginId, password); - Page items = productService.findProductList(brandId, memberId, sortFilter, pageable); - return items.map(FindProductListResDto::from); + Long memberId = resolveMemberId(req.loginId(), req.password()); + + Page products = productService.findProductList(req.brandId(), req.sortFilter(), pageable); + List productIds = products.getContent().stream().map(ProductItem::id).toList(); + Set favoriteIds = favoriteService.getFavoriteProductIds(memberId, productIds); + + return products.map(item -> FindProductListResDto.from( + item.withIsFavorite(favoriteIds.contains(item.id())) + )); } public FindProductResDto findProduct(String loginId, String password, Long productId) { - Product product = productService.findProduct(productId); - Brand brand = brandService.findBrand(product.getBrandId()); - - long favoriteCnt = favoriteService.countByProductId(productId); Long memberId = resolveMemberId(loginId, password); - boolean isFavorite = memberId != null && favoriteService.existsByMemberIdAndProductId(memberId, productId); - - return FindProductResDto.of(product, brand, favoriteCnt, isFavorite); + ProductItem item = productService.findProductDetail(productId); + boolean isFavorite = memberId != null + && favoriteService.existsByMemberIdAndProductId(memberId, productId); + return FindProductResDto.from(item.withIsFavorite(isFavorite)); } private Long resolveMemberId(String loginId, String password) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java new file mode 100644 index 000000000..98de04676 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductListReqDto.java @@ -0,0 +1,11 @@ +package com.loopers.application.product.dto; + +import com.loopers.support.enums.SortFilter; + +public record FindProductListReqDto( + String loginId, + String password, + Long brandId, + SortFilter sortFilter +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductResDto.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductResDto.java index 716214a68..d6c813234 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductResDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/FindProductResDto.java @@ -1,7 +1,6 @@ package com.loopers.application.product.dto; -import com.loopers.domain.brand.model.Brand; -import com.loopers.domain.product.model.Product; +import com.loopers.domain.product.model.ProductItem; public record FindProductResDto( Long id, @@ -14,17 +13,17 @@ public record FindProductResDto( long favoriteCnt, boolean isFavorite ) { - public static FindProductResDto of(Product product, Brand brand, long favoriteCnt, boolean isFavorite) { + public static FindProductResDto from(ProductItem item) { return new FindProductResDto( - product.getId(), - product.getName().value(), - brand.getId(), - brand.getName().value(), - product.getPrice().value(), - product.getStock().value(), - product.getDisplayStatus().name(), - favoriteCnt, - isFavorite + item.id(), + item.name(), + item.brandId(), + item.brandName(), + item.price(), + item.stock(), + item.displayStatus(), + item.favoriteCnt(), + item.isFavorite() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/repository/FavoriteRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/repository/FavoriteRepository.java index 1f7915636..4478f88a4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/repository/FavoriteRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/repository/FavoriteRepository.java @@ -2,6 +2,7 @@ import com.loopers.domain.favorite.model.Favorite; +import java.util.List; import java.util.Optional; public interface FavoriteRepository { @@ -10,6 +11,8 @@ public interface FavoriteRepository { Optional findByMemberIdAndProductId(Long memberId, Long productId); + List findByMemberIdAndProductIds(Long memberId, List productIds); + void delete(Favorite favorite); boolean existsByMemberIdAndProductId(Long memberId, Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java index 9bb89ed90..9ec53f166 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java @@ -9,21 +9,27 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class FavoriteService { private final FavoriteRepository favoriteRepository; - public void addFavorite(FavoriteCommand.Add command) { + public boolean addFavorite(FavoriteCommand.Add command) { if (favoriteRepository.existsByMemberIdAndProductId(command.memberId(), command.productId())) { - return; + return false; } try { Favorite favorite = Favorite.create(command.memberId(), command.productId()); favoriteRepository.save(favorite); + return true; } catch (DataIntegrityViolationException e) { // 동시 요청으로 중복 등록 시도 — 이미 등록된 것이므로 무시 + return false; } } @@ -40,4 +46,14 @@ public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { public long countByProductId(Long productId) { return favoriteRepository.countByProductId(productId); } + + public Set getFavoriteProductIds(Long memberId, List productIds) { + if (memberId == null || productIds.isEmpty()) { + return Set.of(); + } + return favoriteRepository.findByMemberIdAndProductIds(memberId, productIds) + .stream() + .map(Favorite::getProductId) + .collect(Collectors.toSet()); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java index ef942ec50..257ebf5ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/model/Product.java @@ -15,6 +15,7 @@ public class Product { private Money price; private Stock stock; private DisplayStatus displayStatus; + private long likeCount; private Product(Long brandId, ProductName name, Money price, Stock stock, DisplayStatus displayStatus) { this.brandId = brandId; @@ -22,6 +23,7 @@ private Product(Long brandId, ProductName name, Money price, Stock stock, Displa this.price = price; this.stock = stock; this.displayStatus = displayStatus; + this.likeCount = 0; } public static Product create(Long brandId, ProductCommand.Create command) { @@ -34,7 +36,7 @@ public static Product create(Long brandId, ProductCommand.Create command) { ); } - public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus) { + public static Product reconstruct(Long id, Long brandId, String name, int price, int stock, DisplayStatus displayStatus, long likeCount) { Product product = new Product( brandId, new ProductName(name), @@ -43,6 +45,7 @@ public static Product reconstruct(Long id, Long brandId, String name, int price, displayStatus ); product.id = id; + product.likeCount = likeCount; return product; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/model/ProductItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/model/ProductItem.java index 401c6b357..4abe7db91 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/model/ProductItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/model/ProductItem.java @@ -10,4 +10,12 @@ public record ProductItem( String displayStatus, long favoriteCnt, boolean isFavorite -) {} +) { + public ProductItem withIsFavorite(boolean isFavorite) { + return new ProductItem(id, name, brandId, brandName, price, stock, displayStatus, favoriteCnt, isFavorite); + } + + public ProductItem withFavoriteCnt(long favoriteCnt) { + return new ProductItem(id, name, brandId, brandName, price, stock, displayStatus, favoriteCnt, isFavorite); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java new file mode 100644 index 000000000..6998ccaec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java @@ -0,0 +1,31 @@ +package com.loopers.domain.product.repository; + +import com.loopers.domain.product.model.ProductItem; + +import java.util.List; +import java.util.Optional; + +public interface ProductCacheRepository { + + Optional get(Long productId); + + void put(Long productId, ProductItem item); + + void evict(Long productId); + + record CachedPage(List items, long totalElements) {} + + Optional getFirstPage(); + + void putFirstPage(List items, long totalElements); + + void evictFirstPage(); + + Optional getLikeCount(Long productId); + + void initLikeCount(Long productId, long count); + + void incrementLikeCount(Long productId); + + void decrementLikeCount(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCustomRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCustomRepository.java index 6b3402ade..d7c227e0f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCustomRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCustomRepository.java @@ -1,6 +1,5 @@ package com.loopers.domain.product.repository; -import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.model.ProductItem; import com.loopers.support.enums.SortFilter; import org.springframework.data.domain.Page; @@ -10,6 +9,6 @@ public interface ProductCustomRepository { - Page findProductList(Long brandId, Long memberId, SortFilter sortFilter, Pageable pageable); - Optional findProduct(Long productId, Long memberId); + Page findProductList(Long brandId, SortFilter sortFilter, Pageable pageable); + Optional findProduct(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java index 280cbceb9..0e55258c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java @@ -24,4 +24,8 @@ public interface ProductRepository { List findByIds(List ids); int decreaseStock(Long id, int quantity); + + int increaseLikeCount(Long id); + + int decreaseLikeCount(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java index 357a96733..89483f7ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java @@ -3,6 +3,7 @@ import com.loopers.domain.product.model.Product; import com.loopers.domain.product.model.ProductCommand; import com.loopers.domain.product.model.ProductItem; +import com.loopers.domain.product.repository.ProductCacheRepository; import com.loopers.domain.product.repository.ProductCustomRepository; import com.loopers.domain.product.repository.ProductRepository; import com.loopers.support.enums.SortFilter; @@ -10,10 +11,12 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.util.List; +import java.util.Optional; @RequiredArgsConstructor @Component @@ -21,6 +24,7 @@ public class ProductService { private final ProductRepository productRepository; private final ProductCustomRepository productCustomRepository; + private final ProductCacheRepository productCacheRepository; public Product createProduct(Long brandId, ProductCommand.Create command) { Product product = Product.create(brandId, command); @@ -45,13 +49,40 @@ public void deleteProductsByBrandId(Long brandId) { productRepository.deleteByBrandId(brandId); } - public Page findProductList(Long brandId, Long memberId, SortFilter sortFilter, Pageable pageable) { - return productCustomRepository.findProductList(brandId, memberId, sortFilter, pageable); + public Page findProductList(Long brandId, SortFilter sortFilter, Pageable pageable) { + if (isFirstPageLatest(brandId, sortFilter, pageable)) { + Optional cached = productCacheRepository.getFirstPage(); + if (cached.isPresent()) { + List items = cached.get().items().stream() + .map(item -> item.withFavoriteCnt(resolveLikeCount(item.id()))) + .toList(); + return new PageImpl<>(items, pageable, cached.get().totalElements()); + } + } + + Page result = productCustomRepository.findProductList(brandId, sortFilter, pageable); + + if (isFirstPageLatest(brandId, sortFilter, pageable)) { + productCacheRepository.putFirstPage(result.getContent(), result.getTotalElements()); + result.getContent().forEach(item -> + productCacheRepository.initLikeCount(item.id(), item.favoriteCnt())); + } + + return result; } - public ProductItem findProduct(Long productId, Long memberId) { - return productCustomRepository.findProduct(productId, memberId) + public ProductItem findProductDetail(Long productId) { + Optional cached = productCacheRepository.get(productId); + if (cached.isPresent()) { + long likeCount = resolveLikeCount(productId); + return cached.get().withFavoriteCnt(likeCount); + } + + ProductItem item = productCustomRepository.findProduct(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + productCacheRepository.put(productId, item); + productCacheRepository.initLikeCount(productId, item.favoriteCnt()); + return item; } public Product findProduct(Long productId) { @@ -59,6 +90,16 @@ public Product findProduct(Long productId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); } + public void increaseLikeCount(Long productId) { + productRepository.increaseLikeCount(productId); + productCacheRepository.incrementLikeCount(productId); + } + + public void decreaseLikeCount(Long productId) { + productRepository.decreaseLikeCount(productId); + productCacheRepository.decrementLikeCount(productId); + } + public void decreaseStock(Product product, int quantity) { product.decreaseStock(quantity); productRepository.update(product); @@ -72,6 +113,8 @@ public Product decreaseStockAtomic(Long productId, int quantity) { if (updatedRows == 0) { throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); } + productCacheRepository.evict(productId); + productCacheRepository.evictFirstPage(); return productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); } @@ -87,4 +130,19 @@ public List getProductsByIds(List productIds) { } return products; } + + private boolean isFirstPageLatest(Long brandId, SortFilter sortFilter, Pageable pageable) { + return brandId == null && sortFilter == SortFilter.LATEST && pageable.getPageNumber() == 0; + } + + private long resolveLikeCount(Long productId) { + return productCacheRepository.getLikeCount(productId) + .orElseGet(() -> { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + long count = product.getLikeCount(); + productCacheRepository.initLikeCount(productId, count); + return count; + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java index 55ac8f39b..8a53bec55 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/entity/FavoriteEntity.java @@ -8,8 +8,9 @@ @Getter @Entity -@Table(name = "favorite", uniqueConstraints = - @UniqueConstraint(columnNames = {"memberId", "productId"})) +@Table(name = "favorite", + uniqueConstraints = @UniqueConstraint(columnNames = {"memberId", "productId"}), + indexes = @Index(name = "idx_favorite_product_id", columnList = "productId")) public class FavoriteEntity extends BaseEntity { @Comment("회원 id") diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java index d81a1eea9..d4b6c93d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/FavoriteJpaRepository.java @@ -3,12 +3,15 @@ import com.loopers.infrastructure.favorite.entity.FavoriteEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface FavoriteJpaRepository extends JpaRepository { Optional findByMemberIdAndProductId(Long memberId, Long productId); + List findByMemberIdAndProductIdIn(Long memberId, List productIds); + boolean existsByMemberIdAndProductId(Long memberId, Long productId); long countByProductId(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java index a0dc7304f..a4d496d51 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/favorite/repository/impl/FavoriteRepositoryImpl.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -41,4 +42,12 @@ public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { public long countByProductId(Long productId) { return favoriteJpaRepository.countByProductId(productId); } + + @Override + public List findByMemberIdAndProductIds(Long memberId, List productIds) { + return favoriteJpaRepository.findByMemberIdAndProductIdIn(memberId, productIds) + .stream() + .map(FavoriteEntity::toModel) + .toList(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheDto.java new file mode 100644 index 000000000..3e5ee1153 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheDto.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.product.cache; + +public record ProductCacheDto( + Long id, + String name, + Long brandId, + String brandName, + int price, + int stock, + String displayStatus +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java new file mode 100644 index 000000000..0960fe5c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java @@ -0,0 +1,93 @@ +package com.loopers.infrastructure.product.cache; + +import com.loopers.domain.product.model.ProductItem; +import com.loopers.domain.product.repository.ProductCacheRepository; +import com.loopers.support.cache.RedisCacheManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductCacheRepositoryImpl implements ProductCacheRepository { + + private static final String KEY_PREFIX = "product:"; + private static final String LIKES_SUFFIX = ":likes"; + private static final String FIRST_PAGE_KEY = "product-list:first-page"; + private static final long TTL_SECONDS = 3600; + private static final long FIRST_PAGE_TTL_SECONDS = 300; + + private final RedisCacheManager redisCacheManager; + + @Override + public Optional get(Long productId) { + return redisCacheManager.get(KEY_PREFIX + productId, ProductCacheDto.class) + .map(this::toItem); + } + + @Override + public void put(Long productId, ProductItem item) { + redisCacheManager.put(KEY_PREFIX + productId, toDto(item), TTL_SECONDS); + } + + @Override + public void evict(Long productId) { + redisCacheManager.evict(KEY_PREFIX + productId); + } + + @Override + public Optional getFirstPage() { + return redisCacheManager.get(FIRST_PAGE_KEY, ProductListCacheDto.class) + .map(dto -> new CachedPage( + dto.items().stream().map(this::toItem).toList(), + dto.totalElements() + )); + } + + @Override + public void putFirstPage(List items, long totalElements) { + List dtos = items.stream().map(this::toDto).toList(); + redisCacheManager.put(FIRST_PAGE_KEY, new ProductListCacheDto(dtos, totalElements), FIRST_PAGE_TTL_SECONDS); + } + + @Override + public void evictFirstPage() { + redisCacheManager.evict(FIRST_PAGE_KEY); + } + + @Override + public Optional getLikeCount(Long productId) { + return redisCacheManager.getCount(KEY_PREFIX + productId + LIKES_SUFFIX); + } + + @Override + public void initLikeCount(Long productId, long count) { + redisCacheManager.setCount(KEY_PREFIX + productId + LIKES_SUFFIX, count, TTL_SECONDS); + } + + @Override + public void incrementLikeCount(Long productId) { + redisCacheManager.increment(KEY_PREFIX + productId + LIKES_SUFFIX); + } + + @Override + public void decrementLikeCount(Long productId) { + redisCacheManager.decrement(KEY_PREFIX + productId + LIKES_SUFFIX); + } + + private ProductCacheDto toDto(ProductItem item) { + return new ProductCacheDto( + item.id(), item.name(), item.brandId(), item.brandName(), + item.price(), item.stock(), item.displayStatus() + ); + } + + private ProductItem toItem(ProductCacheDto dto) { + return new ProductItem( + dto.id(), dto.name(), dto.brandId(), dto.brandName(), + dto.price(), dto.stock(), dto.displayStatus(), 0L, false + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductListCacheDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductListCacheDto.java new file mode 100644 index 000000000..3b4c549f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductListCacheDto.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.product.cache; + +import java.util.List; + +public record ProductListCacheDto( + List items, + long totalElements +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java index c3694220b..2374b5887 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductEntity.java @@ -7,13 +7,19 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.Getter; import org.hibernate.annotations.SQLRestriction; @Getter @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_price", columnList = "price"), + @Index(name = "idx_product_like_count", columnList = "likeCount"), + @Index(name = "idx_product_brand_price", columnList = "brandId, price"), + @Index(name = "idx_product_brand_like", columnList = "brandId, likeCount") +}) @SQLRestriction("deleted_at IS NULL") public class ProductEntity extends BaseEntity { @@ -33,6 +39,9 @@ public class ProductEntity extends BaseEntity { @Enumerated(EnumType.STRING) private DisplayStatus displayStatus; + @Column(nullable = false) + private long likeCount; + protected ProductEntity() {} private ProductEntity(Long brandId, String name, int price, int stock, DisplayStatus displayStatus) { @@ -41,6 +50,7 @@ private ProductEntity(Long brandId, String name, int price, int stock, DisplaySt this.price = price; this.stock = stock; this.displayStatus = displayStatus; + this.likeCount = 0; } public static ProductEntity toEntity(Product product) { @@ -60,7 +70,8 @@ public Product toModel() { this.name, this.price, this.stock, - this.displayStatus + this.displayStatus, + this.likeCount ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java new file mode 100644 index 000000000..6e5b6c153 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java @@ -0,0 +1,29 @@ +package com.loopers.infrastructure.product.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_like_stats") +public class ProductLikeStatsEntity { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(nullable = false) + private long likeCount; + + protected ProductLikeStatsEntity() {} + + public static ProductLikeStatsEntity create(Long productId) { + ProductLikeStatsEntity entity = new ProductLikeStatsEntity(); + entity.productId = productId; + entity.likeCount = 0; + return entity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductJpaRepository.java index bcc83674b..196832d9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductJpaRepository.java @@ -28,4 +28,12 @@ public interface ProductJpaRepository extends JpaRepository @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM ProductEntity p WHERE p.id = :id") Optional findByIdWithPessimisticLock(@Param("id") Long id); + + @Modifying + @Query("UPDATE ProductEntity p SET p.likeCount = p.likeCount + 1 WHERE p.id = :id") + int increaseLikeCount(@Param("id") Long id); + + @Modifying + @Query("UPDATE ProductEntity p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") + int decreaseLikeCount(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java new file mode 100644 index 000000000..dd2730a13 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.product.repository; + +import com.loopers.infrastructure.product.entity.ProductLikeStatsEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductLikeStatsJpaRepository extends JpaRepository { + + @Modifying + @Query("UPDATE ProductLikeStatsEntity s SET s.likeCount = s.likeCount + 1 WHERE s.productId = :productId") + int increaseLikeCount(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE ProductLikeStatsEntity s SET s.likeCount = s.likeCount - 1 WHERE s.productId = :productId AND s.likeCount > 0") + int decreaseLikeCount(@Param("productId") Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java index 91718456f..01fcfd95a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java @@ -1,17 +1,12 @@ package com.loopers.infrastructure.product.repository.impl; -import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.repository.ProductCustomRepository; -import com.loopers.infrastructure.favorite.entity.QFavoriteEntity; -import com.loopers.infrastructure.product.entity.QProductEntity; import com.loopers.support.enums.SortFilter; import com.loopers.support.util.BooleanBuilderUtil; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.Projections; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -33,13 +28,7 @@ public class ProductCustomRepositoryImpl implements ProductCustomRepository { private final JPAQueryFactory queryFactory; @Override - public Page findProductList(Long brandId, Long memberId, SortFilter sortFilter, Pageable pageable) { - - QFavoriteEntity favorite = new QFavoriteEntity("favorite"); - QFavoriteEntity myFavorite = new QFavoriteEntity("myFavorite"); - - NumberExpression favoriteCnt = favorite.id.count(); - BooleanExpression isFavorite = memberId != null ? myFavorite.id.count().gt(0L) : Expressions.asBoolean(false); + public Page findProductList(Long brandId, SortFilter sortFilter, Pageable pageable) { List content = queryFactory .select(Projections.constructor(ProductItem.class, @@ -50,16 +39,13 @@ public Page findProductList(Long brandId, Long memberId, SortFilter productEntity.price, productEntity.stock, productEntity.displayStatus, - favoriteCnt, - isFavorite + productEntity.likeCount, + Expressions.asBoolean(false) )) .from(productEntity) .innerJoin(brandEntity).on(productEntity.brandId.eq(brandEntity.id)) - .leftJoin(favorite).on(favorite.productId.eq(productEntity.id)) - .leftJoin(myFavorite).on(myFavorite.productId.eq(productEntity.id), myFavorite.memberId.eq(memberId != null ? memberId : 0L)) .where(whereProductList(brandId)) - .groupBy(productEntity.id) - .orderBy(sortFilter.toOrderSpecifier(productEntity, favoriteCnt)) + .orderBy(sortFilter.toOrderSpecifier(productEntity)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); @@ -72,12 +58,7 @@ public Page findProductList(Long brandId, Long memberId, SortFilter } @Override - public Optional findProduct(Long productId, Long memberId) { - QFavoriteEntity favorite = new QFavoriteEntity("favorite"); - QFavoriteEntity myFavorite = new QFavoriteEntity("myFavorite"); - - NumberExpression favoriteCnt = favorite.id.count(); - BooleanExpression isFavorite = memberId != null ? myFavorite.id.count().gt(0L) : Expressions.asBoolean(false); + public Optional findProduct(Long productId) { ProductItem result = queryFactory .select(Projections.constructor(ProductItem.class, @@ -88,18 +69,15 @@ public Optional findProduct(Long productId, Long memberId) { productEntity.price, productEntity.stock, productEntity.displayStatus, - favoriteCnt, - isFavorite + productEntity.likeCount, + Expressions.asBoolean(false) )) .from(productEntity) .innerJoin(brandEntity).on(productEntity.brandId.eq(brandEntity.id)) - .leftJoin(favorite).on(favorite.productId.eq(productEntity.id)) - .leftJoin(myFavorite).on(myFavorite.productId.eq(productEntity.id), myFavorite.memberId.eq(memberId != null ? memberId : 0L)) .where( productEntity.id.eq(productId), productEntity.deletedAt.isNull() ) - .groupBy(productEntity.id) .fetchOne(); return Optional.ofNullable(result); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductRepositoryImpl.java index 9bb3fdbc0..cb654a9e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductRepositoryImpl.java @@ -71,4 +71,14 @@ public List findByIds(List ids) { public int decreaseStock(Long id, int quantity) { return productJpaRepository.decreaseStock(id, quantity); } + + @Override + public int increaseLikeCount(Long id) { + return productJpaRepository.increaseLikeCount(id); + } + + @Override + public int decreaseLikeCount(Long id) { + return productJpaRepository.decreaseLikeCount(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 6e0b1a722..3767efe5a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.dto.FindProductListReqDto; import com.loopers.application.product.dto.FindProductListResDto; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.product.dto.FindProductApiResDto; @@ -32,7 +33,8 @@ public ApiResponse> findProductList(@RequestHeade @RequestHeader(value = HEADER_LOGIN_PW, required = false) String password, @RequestParam(required = false) Long brandId, @RequestParam SortFilter sortFilter, Pageable pageable) { - Page productList = productFacade.findProductList(loginId, password, brandId, sortFilter, pageable); + FindProductListReqDto req = new FindProductListReqDto(loginId, password, brandId, sortFilter); + Page productList = productFacade.findProductList(req, pageable); return ApiResponse.success(productList.map(FindProductListApiResDto::from)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java new file mode 100644 index 000000000..ad5dbb587 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java @@ -0,0 +1,98 @@ +package com.loopers.support.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +public class RedisCacheManager { + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final ObjectMapper objectMapper; + + public RedisCacheManager( + RedisTemplate readTemplate, + @Qualifier("redisTemplateMaster") RedisTemplate writeTemplate, + ObjectMapper objectMapper + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.objectMapper = objectMapper; + } + + public Optional get(String key, Class type) { + try { + String json = readTemplate.opsForValue().get(key); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, type)); + } catch (Exception e) { + log.warn("Redis GET 실패 - key: {}", key, e); + return Optional.empty(); + } + } + + public void put(String key, Object value, long ttlSeconds) { + try { + String json = objectMapper.writeValueAsString(value); + writeTemplate.opsForValue().set(key, json, ttlSeconds, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("Redis SET 실패 - key: {}", key, e); + } + } + + public void evict(String key) { + try { + writeTemplate.delete(key); + } catch (Exception e) { + log.warn("Redis DEL 실패 - key: {}", key, e); + } + } + + public Long increment(String key) { + try { + return writeTemplate.opsForValue().increment(key); + } catch (Exception e) { + log.warn("Redis INCR 실패 - key: {}", key, e); + return null; + } + } + + public Long decrement(String key) { + try { + return writeTemplate.opsForValue().decrement(key); + } catch (Exception e) { + log.warn("Redis DECR 실패 - key: {}", key, e); + return null; + } + } + + public Optional getCount(String key) { + try { + String value = readTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(Long.parseLong(value)); + } catch (Exception e) { + log.warn("Redis GET count 실패 - key: {}", key, e); + return Optional.empty(); + } + } + + public void setCount(String key, long value, long ttlSeconds) { + try { + writeTemplate.opsForValue().set(key, String.valueOf(value), ttlSeconds, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("Redis SET count 실패 - key: {}", key, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java index 9378398d5..b8f7bc5c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java @@ -1,7 +1,6 @@ package com.loopers.support.enums; import com.querydsl.core.types.OrderSpecifier; -import com.querydsl.core.types.dsl.NumberExpression; import com.loopers.infrastructure.product.entity.QProductEntity; public enum SortFilter { @@ -9,12 +8,11 @@ public enum SortFilter { PRICE_ASC, LIKES_DESC; - public OrderSpecifier toOrderSpecifier(QProductEntity product, - NumberExpression favoriteCnt) { + public OrderSpecifier toOrderSpecifier(QProductEntity product) { return switch (this) { case LATEST -> product.id.desc(); case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> favoriteCnt.desc(); + case LIKES_DESC -> product.likeCount.desc(); }; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java new file mode 100644 index 000000000..583c0d026 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIndexPerformanceTest.java @@ -0,0 +1,394 @@ +package com.loopers.domain.product; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProductIndexPerformanceTest { + + @Autowired + private EntityManagerFactory entityManagerFactory; + + // 테스트 시나리오 정의 + private static final String[] QUERY_NAMES = { + "전체 최신순", + "전체 가격순", + "전체 좋아요순", + "브랜드별 최신순", + "브랜드별 가격순", + "브랜드별 좋아요순", + "favorite countByProductId" + }; + + private static final String[] QUERY_SQLS = { + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 20", + + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.deleted_at IS NULL ORDER BY p.price ASC LIMIT 20", + + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.deleted_at IS NULL ORDER BY p.like_count DESC LIMIT 20", + + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.brand_id = 1 AND p.deleted_at IS NULL ORDER BY p.id DESC LIMIT 20", + + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.brand_id = 1 AND p.deleted_at IS NULL ORDER BY p.price ASC LIMIT 20", + + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p WHERE p.brand_id = 1 AND p.deleted_at IS NULL ORDER BY p.like_count DESC LIMIT 20", + + "SELECT COUNT(*) FROM favorite WHERE product_id = 1" + }; + + // 후보 인덱스 정의: [인덱스명, CREATE문, DROP문, 설명] + private static final String[][] INDEX_CANDIDATES = { + {"idx_product_price", "CREATE INDEX idx_product_price ON product(price)", "DROP INDEX idx_product_price ON product", "단일: price"}, + {"idx_product_like_count", "CREATE INDEX idx_product_like_count ON product(like_count)", "DROP INDEX idx_product_like_count ON product", "단일: like_count"}, + {"idx_product_brand_price", "CREATE INDEX idx_product_brand_price ON product(brand_id, price)","DROP INDEX idx_product_brand_price ON product", "복합: brand_id + price"}, + {"idx_product_brand_like", "CREATE INDEX idx_product_brand_like ON product(brand_id, like_count)","DROP INDEX idx_product_brand_like ON product","복합: brand_id + like_count"}, + {"idx_product_brand_like_price", "CREATE INDEX idx_product_brand_like_price ON product(brand_id, like_count, price)","DROP INDEX idx_product_brand_like_price ON product","복합: brand_id + like_count + price (3컬럼)"}, + {"idx_favorite_product_id", "CREATE INDEX idx_favorite_product_id ON favorite(product_id)", "DROP INDEX idx_favorite_product_id ON favorite", "단일: product_id (favorite)"}, + }; + + @BeforeAll + void setUp() { + EntityManager em = entityManagerFactory.createEntityManager(); + em.getTransaction().begin(); + + // 브랜드 10개 + for (int i = 1; i <= 10; i++) { + em.createNativeQuery( + "INSERT INTO brand (id, name, description, created_at, updated_at) VALUES (:id, :name, :desc, NOW(), NOW())") + .setParameter("id", i) + .setParameter("name", "Brand-" + i) + .setParameter("desc", "Desc-" + i) + .executeUpdate(); + } + + // 상품 10만건 + for (int batch = 0; batch < 100; batch++) { + StringBuilder sql = new StringBuilder( + "INSERT INTO product (brand_id, name, price, stock, like_count, display_status, created_at, updated_at) VALUES "); + for (int i = 0; i < 1000; i++) { + int idx = batch * 1000 + i; + long brandId = (idx % 10) + 1; + int price = 1000 + (idx * 7) % 99000; + int stock = 1 + (idx * 3) % 500; + if (i > 0) sql.append(","); + sql.append(String.format("(%d, 'Product-%d', %d, %d, 0, 'DISPLAYING', NOW(), NOW())", + brandId, idx, price, stock)); + } + em.createNativeQuery(sql.toString()).executeUpdate(); + } + + // 멤버 10명 + for (int i = 1; i <= 10; i++) { + em.createNativeQuery( + "INSERT INTO member (id, login_id, password, name, email, birth_date, created_at, updated_at) " + + "VALUES (:id, :loginId, 'pass', :name, :email, '1990-01-01', NOW(), NOW())") + .setParameter("id", i) + .setParameter("loginId", "user" + i) + .setParameter("name", "User-" + i) + .setParameter("email", "user" + i + "@test.com") + .executeUpdate(); + } + + // favorite: 멤버 10명 x 상품 랜덤 좋아요 (약 5%) + em.createNativeQuery( + "INSERT INTO favorite (member_id, product_id, created_at, updated_at) " + + "SELECT m.id, p.id, NOW(), NOW() " + + "FROM member m CROSS JOIN product p " + + "WHERE RAND() < 0.05") + .executeUpdate(); + + em.getTransaction().commit(); + + // product.like_count 동기화 + em.getTransaction().begin(); + em.createNativeQuery( + "UPDATE product p SET p.like_count = " + + "(SELECT COUNT(*) FROM favorite f WHERE f.product_id = p.id)") + .executeUpdate(); + em.getTransaction().commit(); + + em.close(); + } + + @Test + void indexPerformanceBenchmark() { + EntityManager em = entityManagerFactory.createEntityManager(); + + // ═══ 1단계: 베이스라인 (인덱스 없음) ═══ + System.out.println("\n========================================"); + System.out.println(" [BASELINE] 인덱스 없는 상태 측정"); + System.out.println("========================================"); + List baseline = measureAllQueries(em); + + // ═══ 2단계: 각 인덱스별 개별 측정 ═══ + // key=인덱스명, value=해당 인덱스만 적용했을 때 전체 쿼리 결과 + Map> perIndexResults = new LinkedHashMap<>(); + + for (String[] candidate : INDEX_CANDIDATES) { + String indexName = candidate[0]; + String createSql = candidate[1]; + String dropSql = candidate[2]; + String desc = candidate[3]; + + System.out.println("\n========================================"); + System.out.printf(" [INDEX] %s (%s) 적용 후 측정%n", indexName, desc); + System.out.println("========================================"); + + // CREATE + em.getTransaction().begin(); + em.createNativeQuery(createSql).executeUpdate(); + em.getTransaction().commit(); + + // 측정 + List results = measureAllQueries(em); + perIndexResults.put(indexName, results); + + // DROP (다음 인덱스 개별 테스트를 위해 원복) + em.getTransaction().begin(); + em.createNativeQuery(dropSql).executeUpdate(); + em.getTransaction().commit(); + } + + // ═══ 3단계: 전체 인덱스 동시 적용 ═══ + System.out.println("\n========================================"); + System.out.println(" [ALL INDEXES] 전체 인덱스 적용 후 측정"); + System.out.println("========================================"); + + em.getTransaction().begin(); + for (String[] candidate : INDEX_CANDIDATES) { + em.createNativeQuery(candidate[1]).executeUpdate(); + } + em.getTransaction().commit(); + + List allIndexResults = measureAllQueries(em); + + em.close(); + + // ═══ 4단계: 결과 출력 ═══ + printPerIndexComparisonTable(baseline, perIndexResults); + printFinalComparisonTable(baseline, allIndexResults); + printConclusion(baseline, perIndexResults, allIndexResults); + } + + // ─── 측정 헬퍼 ───────────────────────────────────────────────── + + private List measureAllQueries(EntityManager em) { + List results = new ArrayList<>(); + for (int i = 0; i < QUERY_SQLS.length; i++) { + results.add(measureQuery(em, QUERY_NAMES[i], QUERY_SQLS[i])); + } + return results; + } + + private QueryResult measureQuery(EntityManager em, String name, String sql) { + // EXPLAIN + String accessType = "N/A", possibleKeys = "NULL", keyUsed = "NULL", + keyLen = "NULL", rowsStr = "N/A", extraStr = ""; + + try { + @SuppressWarnings("unchecked") + List explainRows = em.createNativeQuery("EXPLAIN " + sql).getResultList(); + if (!explainRows.isEmpty()) { + Object[] row = explainRows.get(0); + for (Object[] r : explainRows) { + String tableName = r[2] != null ? String.valueOf(r[2]) : ""; + if ("p".equals(tableName) || "product".equals(tableName) || "favorite".equals(tableName)) { + row = r; + break; + } + } + accessType = str(row[4]); + possibleKeys = str(row[5]); + keyUsed = str(row[6]); + keyLen = str(row[7]); + rowsStr = str(row[9]); + extraStr = str(row[11]); + } + } catch (Exception e) { + System.out.println(" EXPLAIN 오류 [" + name + "]: " + e.getMessage()); + } + + // 워밍업 1회 + try { em.createNativeQuery(sql).getResultList(); } catch (Exception ignored) {} + + // 10회 반복 측정 + long total = 0; + for (int i = 0; i < 10; i++) { + long start = System.currentTimeMillis(); + em.createNativeQuery(sql).getResultList(); + total += System.currentTimeMillis() - start; + } + double avgMs = total / 10.0; + + System.out.printf(" %-28s | avg=%.1fms | type=%-6s | key=%-30s | rows=%-8s | Extra=%s%n", + name, avgMs, accessType, keyUsed, rowsStr, extraStr); + + return new QueryResult(name, avgMs, accessType, possibleKeys, keyUsed, keyLen, rowsStr, extraStr); + } + + // ─── 출력 헬퍼 ───────────────────────────────────────────────── + + private void printPerIndexComparisonTable(List baseline, Map> perIndex) { + System.out.println(); + System.out.println("┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"); + System.out.println("│ 각 인덱스별 개별 성능 비교 (10만건 기준) │"); + System.out.println("└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"); + + for (Map.Entry> entry : perIndex.entrySet()) { + String indexName = entry.getKey(); + List indexResults = entry.getValue(); + + System.out.println(); + System.out.printf("▶ %s%n", indexName); + System.out.println("┌──────────────────────────────┬──────────┬──────────┬──────────────────────┬──────────────────────┬──────────┐"); + System.out.println("│ 쿼리 │ Before │ After │ EXPLAIN Before │ EXPLAIN After │ 개선율 │"); + System.out.println("├──────────────────────────────┼──────────┼──────────┼──────────────────────┼──────────────────────┼──────────┤"); + + for (int i = 0; i < baseline.size(); i++) { + QueryResult b = baseline.get(i); + QueryResult a = indexResults.get(i); + + String improvement = calcImprovement(b.avgMs, a.avgMs); + String explainBefore = String.format("%s/%s", b.accessType, b.keyUsed); + String explainAfter = String.format("%s/%s", a.accessType, a.keyUsed); + + // 변화가 있는 행 강조 + boolean changed = !b.keyUsed.equals(a.keyUsed) || !b.accessType.equals(a.accessType); + String marker = changed ? " ★" : ""; + + System.out.printf("│ %-28s │ %6.1fms │ %6.1fms │ %-20s │ %-20s │ %7s%s │%n", + trunc(b.name, 28), b.avgMs, a.avgMs, + trunc(explainBefore, 20), trunc(explainAfter, 20), + improvement, marker); + } + System.out.println("└──────────────────────────────┴──────────┴──────────┴──────────────────────┴──────────────────────┴──────────┘"); + System.out.println(" ★ = EXPLAIN 실행 계획이 변경된 쿼리"); + } + } + + private void printFinalComparisonTable(List baseline, List allIndex) { + System.out.println(); + System.out.println("┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"); + System.out.println("│ 전체 인덱스 적용 최종 비교 │"); + System.out.println("└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"); + System.out.println("┌──────────────────────────────┬──────────┬──────────┬──────────────────────┬──────────────────────┬──────────┐"); + System.out.println("│ 쿼리 │ Before │ After │ EXPLAIN Before │ EXPLAIN After │ 개선율 │"); + System.out.println("├──────────────────────────────┼──────────┼──────────┼──────────────────────┼──────────────────────┼──────────┤"); + + for (int i = 0; i < baseline.size(); i++) { + QueryResult b = baseline.get(i); + QueryResult a = allIndex.get(i); + + String improvement = calcImprovement(b.avgMs, a.avgMs); + String explainBefore = String.format("%s/%s", b.accessType, b.keyUsed); + String explainAfter = String.format("%s/%s", a.accessType, a.keyUsed); + + System.out.printf("│ %-28s │ %6.1fms │ %6.1fms │ %-20s │ %-20s │ %8s │%n", + trunc(b.name, 28), b.avgMs, a.avgMs, + trunc(explainBefore, 20), trunc(explainAfter, 20), improvement); + } + System.out.println("└──────────────────────────────┴──────────┴──────────┴──────────────────────┴──────────────────────┴──────────┘"); + } + + private void printConclusion(List baseline, Map> perIndex, List allIndex) { + System.out.println(); + System.out.println("┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐"); + System.out.println("│ 인덱스 선택 결론 │"); + System.out.println("└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘"); + System.out.println(); + + for (Map.Entry> entry : perIndex.entrySet()) { + String indexName = entry.getKey(); + List results = entry.getValue(); + + // 해당 인덱스로 인해 EXPLAIN이 변경된 쿼리 찾기 + List improved = new ArrayList<>(); + for (int i = 0; i < baseline.size(); i++) { + QueryResult b = baseline.get(i); + QueryResult a = results.get(i); + if (!b.keyUsed.equals(a.keyUsed) || !b.accessType.equals(a.accessType)) { + String pct = calcImprovement(b.avgMs, a.avgMs); + improved.add(String.format(" - %s: %s/%s → %s/%s (%s)", + b.name, b.accessType, b.keyUsed, a.accessType, a.keyUsed, pct)); + } + } + + System.out.printf(" [%s]%n", indexName); + if (improved.isEmpty()) { + System.out.println(" → 실행 계획 변화 없음 (이미 다른 인덱스/PK로 커버됨)"); + } else { + System.out.println(" → 실행 계획이 개선된 쿼리:"); + improved.forEach(System.out::println); + } + System.out.println(); + } + + System.out.println(" ════════════════════════════════════════════════════════════════════"); + System.out.println(" 최종 선택 인덱스:"); + for (String[] candidate : INDEX_CANDIDATES) { + System.out.printf(" ✓ %s (%s)%n", candidate[0], candidate[3]); + } + System.out.println(); + System.out.println(" 선택 근거:"); + System.out.println(" 1. idx_product_price: 전체 가격순 정렬 시 filesort 제거"); + System.out.println(" 2. idx_product_like_count: 전체 좋아요순 정렬 시 filesort 제거"); + System.out.println(" 3. idx_product_brand_price: 브랜드 필터 + 가격순 정렬을 복합 인덱스로 커버"); + System.out.println(" 4. idx_product_brand_like: 브랜드 필터 + 좋아요순 정렬을 복합 인덱스로 커버"); + System.out.println(" 5. idx_favorite_product_id: countByProductId 풀스캔 → ref 스캔으로 개선"); + System.out.println(); + System.out.println(" 미선택 근거:"); + System.out.println(" - (brand_id, id) 복합 인덱스: brand_id 필터 후 PK 역순 스캔 가능, 복합 인덱스 prefix로도 커버"); + System.out.println(" - (id) 단독 인덱스: PK 클러스터드 인덱스가 이미 존재"); + System.out.println(" ════════════════════════════════════════════════════════════════════"); + } + + // ─── 유틸 ────────────────────────────────────────────────────── + + private String calcImprovement(double before, double after) { + if (before <= 0) return "N/A"; + double pct = (before - after) / before * 100.0; + return String.format("%+.1f%%", pct); + } + + private String str(Object o) { + return o != null ? String.valueOf(o) : "NULL"; + } + + private String trunc(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max - 1) + "…"; + } + + // ─── 결과 레코드 ────────────────────────────────────────────── + + record QueryResult( + String name, + double avgMs, + String accessType, + String possibleKeys, + String keyUsed, + String keyLen, + String rows, + String extra + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java new file mode 100644 index 000000000..7be43eb54 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductQueryPerformanceTest.java @@ -0,0 +1,212 @@ +package com.loopers.domain.product; + +import com.loopers.testcontainers.MySqlTestContainersConfig; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProductQueryPerformanceTest { + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @BeforeAll + void setUp() { + EntityManager em = entityManagerFactory.createEntityManager(); + em.getTransaction().begin(); + + // 브랜드 10개 + for (int i = 1; i <= 10; i++) { + em.createNativeQuery( + "INSERT INTO brand (id, name, description, created_at, updated_at) VALUES (:id, :name, :desc, NOW(), NOW())") + .setParameter("id", i) + .setParameter("name", "Brand-" + i) + .setParameter("desc", "Desc-" + i) + .executeUpdate(); + } + + // 상품 10만건 (like_count = 0) + for (int batch = 0; batch < 100; batch++) { + StringBuilder sql = new StringBuilder( + "INSERT INTO product (brand_id, name, price, stock, like_count, display_status, created_at, updated_at) VALUES "); + for (int i = 0; i < 1000; i++) { + int idx = batch * 1000 + i; + long brandId = (idx % 10) + 1; + int price = 1000 + (idx * 7) % 99000; + int stock = 1 + (idx * 3) % 500; + if (i > 0) sql.append(","); + sql.append(String.format("(%d, 'Product-%d', %d, %d, 0, 'DISPLAYING', NOW(), NOW())", + brandId, idx, price, stock)); + } + em.createNativeQuery(sql.toString()).executeUpdate(); + } + + // 멤버 10명 + for (int i = 1; i <= 10; i++) { + em.createNativeQuery( + "INSERT INTO member (id, login_id, password, name, email, birth_date, created_at, updated_at) " + + "VALUES (:id, :loginId, 'pass', :name, :email, '1990-01-01', NOW(), NOW())") + .setParameter("id", i) + .setParameter("loginId", "user" + i) + .setParameter("name", "User-" + i) + .setParameter("email", "user" + i + "@test.com") + .executeUpdate(); + } + + // favorite: 멤버 10명 x 상품 랜덤 좋아요 (약 5%) + em.createNativeQuery( + "INSERT INTO favorite (member_id, product_id, created_at, updated_at) " + + "SELECT m.id, p.id, NOW(), NOW() " + + "FROM member m CROSS JOIN product p " + + "WHERE RAND() < 0.05") + .executeUpdate(); + + em.getTransaction().commit(); + + // DDL은 별도 트랜잭션 + em.getTransaction().begin(); + em.createNativeQuery( + "CREATE TABLE IF NOT EXISTS product_like_stats (product_id BIGINT PRIMARY KEY, like_count BIGINT NOT NULL DEFAULT 0)") + .executeUpdate(); + em.getTransaction().commit(); + + // stats 테이블 채우기 + product.like_count 동기화 + em.getTransaction().begin(); + em.createNativeQuery( + "INSERT INTO product_like_stats (product_id, like_count) " + + "SELECT p.id, COUNT(f.id) FROM product p LEFT JOIN favorite f ON p.id = f.product_id GROUP BY p.id") + .executeUpdate(); + em.createNativeQuery( + "UPDATE product p JOIN product_like_stats s ON p.id = s.product_id SET p.like_count = s.like_count") + .executeUpdate(); + em.getTransaction().commit(); + + em.close(); + } + + @DisplayName("비정규화 vs MaterializedView vs 정규화 성능 비교") + @Nested + class QueryPerformanceComparison { + + @DisplayName("방식 A: 비정규화 - 브랜드 필터 + 좋아요 순 정렬") + @Test + void denormalization_brandFilter_likeSort() { + EntityManager em = entityManagerFactory.createEntityManager(); + + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY p.like_count DESC LIMIT 20") + .getResultList(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < 10; i++) { + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY p.like_count DESC LIMIT 20") + .getResultList(); + } + long elapsed = System.currentTimeMillis() - start; + + System.out.println("=== 방식 A (비정규화) ==="); + System.out.println("10회 실행 총 시간: " + elapsed + "ms"); + System.out.println("평균: " + (elapsed / 10.0) + "ms"); + + var explain = em.createNativeQuery( + "EXPLAIN SELECT p.id, p.name, p.price, p.stock, p.like_count " + + "FROM product p " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY p.like_count DESC LIMIT 20") + .getResultList(); + System.out.println("EXPLAIN: " + explain); + em.close(); + } + + @DisplayName("방식 B: MaterializedView - 브랜드 필터 + 좋아요 순 정렬") + @Test + void materializedView_brandFilter_likeSort() { + EntityManager em = entityManagerFactory.createEntityManager(); + + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, s.like_count " + + "FROM product p JOIN product_like_stats s ON p.id = s.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY s.like_count DESC LIMIT 20") + .getResultList(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < 10; i++) { + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, s.like_count " + + "FROM product p JOIN product_like_stats s ON p.id = s.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY s.like_count DESC LIMIT 20") + .getResultList(); + } + long elapsed = System.currentTimeMillis() - start; + + System.out.println("=== 방식 B (MaterializedView) ==="); + System.out.println("10회 실행 총 시간: " + elapsed + "ms"); + System.out.println("평균: " + (elapsed / 10.0) + "ms"); + + var explain = em.createNativeQuery( + "EXPLAIN SELECT p.id, p.name, p.price, p.stock, s.like_count " + + "FROM product p JOIN product_like_stats s ON p.id = s.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "ORDER BY s.like_count DESC LIMIT 20") + .getResultList(); + System.out.println("EXPLAIN: " + explain); + em.close(); + } + + @DisplayName("방식 C: 정규화 - LEFT JOIN favorite + COUNT") + @Test + void normalized_brandFilter_likeSort() { + EntityManager em = entityManagerFactory.createEntityManager(); + + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, COUNT(f.id) as like_count " + + "FROM product p LEFT JOIN favorite f ON p.id = f.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "GROUP BY p.id ORDER BY like_count DESC LIMIT 20") + .getResultList(); + + long start = System.currentTimeMillis(); + for (int i = 0; i < 10; i++) { + em.createNativeQuery( + "SELECT p.id, p.name, p.price, p.stock, COUNT(f.id) as like_count " + + "FROM product p LEFT JOIN favorite f ON p.id = f.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "GROUP BY p.id ORDER BY like_count DESC LIMIT 20") + .getResultList(); + } + long elapsed = System.currentTimeMillis() - start; + + System.out.println("=== 방식 C (정규화: LEFT JOIN + COUNT) ==="); + System.out.println("10회 실행 총 시간: " + elapsed + "ms"); + System.out.println("평균: " + (elapsed / 10.0) + "ms"); + + var explain = em.createNativeQuery( + "EXPLAIN SELECT p.id, p.name, p.price, p.stock, COUNT(f.id) as like_count " + + "FROM product p LEFT JOIN favorite f ON p.id = f.product_id " + + "WHERE p.brand_id = 1 AND p.deleted_at IS NULL " + + "GROUP BY p.id ORDER BY like_count DESC LIMIT 20") + .getResultList(); + System.out.println("EXPLAIN: " + explain); + em.close(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java b/apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java new file mode 100644 index 000000000..e40711320 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/cache/RedisCacheManagerTest.java @@ -0,0 +1,157 @@ +package com.loopers.support.cache; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RedisCacheManagerTest { + + private RedisCacheManager redisCacheManager; + + @Mock + private RedisTemplate readTemplate; + + @Mock + private RedisTemplate writeTemplate; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private ValueOperations readValueOps; + + @Mock + private ValueOperations writeValueOps; + + @BeforeEach + void setUp() { + redisCacheManager = new RedisCacheManager(readTemplate, writeTemplate, objectMapper); + } + + @DisplayName("GET 연산") + @Nested + class Get { + + @DisplayName("캐시에 데이터가 있으면 역직렬화하여 반환한다") + @Test + void returnsDeserialized_whenCacheHit() throws Exception { + // arrange + String json = "{\"id\":1}"; + TestDto expected = new TestDto(1L, "test"); + + when(readTemplate.opsForValue()).thenReturn(readValueOps); + when(readValueOps.get("key:1")).thenReturn(json); + when(objectMapper.readValue(json, TestDto.class)).thenReturn(expected); + + // act + Optional result = redisCacheManager.get("key:1", TestDto.class); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().id()).isEqualTo(1L); + } + + @DisplayName("캐시에 데이터가 없으면 빈 Optional을 반환한다") + @Test + void returnsEmpty_whenCacheMiss() { + // arrange + when(readTemplate.opsForValue()).thenReturn(readValueOps); + when(readValueOps.get("key:1")).thenReturn(null); + + // act + Optional result = redisCacheManager.get("key:1", TestDto.class); + + // assert + assertThat(result).isEmpty(); + } + + @DisplayName("Redis 장애 시 빈 Optional을 반환한다") + @Test + void returnsEmpty_whenRedisError() { + // arrange + when(readTemplate.opsForValue()).thenThrow(new RuntimeException("Redis 연결 실패")); + + // act + Optional result = redisCacheManager.get("key:1", TestDto.class); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("PUT 연산") + @Nested + class Put { + + @DisplayName("JSON으로 직렬화하여 TTL과 함께 저장한다") + @Test + void savesWithTtl() throws Exception { + // arrange + TestDto dto = new TestDto(1L, "test"); + String json = "{\"id\":1}"; + + when(objectMapper.writeValueAsString(dto)).thenReturn(json); + when(writeTemplate.opsForValue()).thenReturn(writeValueOps); + + // act + redisCacheManager.put("key:1", dto, 3600); + + // assert + verify(writeValueOps).set("key:1", json, 3600, TimeUnit.SECONDS); + } + + @DisplayName("Redis 장애 시 예외를 던지지 않는다") + @Test + void doesNotThrow_whenRedisError() throws Exception { + // arrange + TestDto dto = new TestDto(1L, "test"); + when(objectMapper.writeValueAsString(dto)).thenReturn("{\"id\":1}"); + when(writeTemplate.opsForValue()).thenThrow(new RuntimeException("Redis 연결 실패")); + + // act & assert (no exception) + redisCacheManager.put("key:1", dto, 3600); + } + } + + @DisplayName("EVICT 연산") + @Nested + class Evict { + + @DisplayName("키를 삭제한다") + @Test + void deletesKey() { + // act + redisCacheManager.evict("key:1"); + + // assert + verify(writeTemplate).delete("key:1"); + } + + @DisplayName("Redis 장애 시 예외를 던지지 않는다") + @Test + void doesNotThrow_whenRedisError() { + // arrange + when(writeTemplate.delete("key:1")).thenThrow(new RuntimeException("Redis 연결 실패")); + + // act & assert (no exception) + redisCacheManager.evict("key:1"); + } + } + + record TestDto(Long id, String name) {} +} From f8cbb543fa74f9c15aa7d228f30deef8ca373cf0 Mon Sep 17 00:00:00 2001 From: dfdf0202 Date: Sun, 15 Mar 2026 18:28:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix=20:=205=EC=A3=BC=EC=B0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../favorite/service/FavoriteService.java | 9 +- .../repository/ProductCacheRepository.java | 2 +- .../product/service/ProductService.java | 6 +- .../cache/ProductCacheRepositoryImpl.java | 4 +- .../entity/ProductLikeStatsEntity.java | 29 --- .../ProductLikeStatsJpaRepository.java | 18 -- .../impl/ProductCustomRepositoryImpl.java | 2 +- .../support/cache/RedisCacheManager.java | 12 ++ .../com/loopers/support/enums/SortFilter.java | 8 +- .../application/brand/BrandFacadeTest.java | 2 +- .../favorite/FavoriteFacadeTest.java | 15 +- .../application/order/OrderFacadeTest.java | 8 +- .../product/ProductFacadeTest.java | 84 ++++---- .../domain/product/ProductServiceTest.java | 202 ++++++++++++++---- .../loopers/domain/product/ProductTest.java | 12 +- .../cache/ProductCacheServiceTest.java | 180 ++++++++++++++++ 16 files changed, 437 insertions(+), 156 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/product/cache/ProductCacheServiceTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java index 9ec53f166..76734ceed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/favorite/service/FavoriteService.java @@ -6,6 +6,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Component; @@ -28,8 +29,10 @@ public boolean addFavorite(FavoriteCommand.Add command) { favoriteRepository.save(favorite); return true; } catch (DataIntegrityViolationException e) { - // 동시 요청으로 중복 등록 시도 — 이미 등록된 것이므로 무시 - return false; + if (e.getCause() instanceof ConstraintViolationException) { + return false; + } + throw e; } } @@ -48,7 +51,7 @@ public long countByProductId(Long productId) { } public Set getFavoriteProductIds(Long memberId, List productIds) { - if (memberId == null || productIds.isEmpty()) { + if (memberId == null || productIds == null || productIds.isEmpty()) { return Set.of(); } return favoriteRepository.findByMemberIdAndProductIds(memberId, productIds) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java index 6998ccaec..223345d69 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductCacheRepository.java @@ -23,7 +23,7 @@ record CachedPage(List items, long totalElements) {} Optional getLikeCount(Long productId); - void initLikeCount(Long productId, long count); + void initLikeCountIfAbsent(Long productId, long count); void incrementLikeCount(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java index 89483f7ce..167fa1c8f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/service/ProductService.java @@ -65,7 +65,7 @@ public Page findProductList(Long brandId, SortFilter sortFilter, Pa if (isFirstPageLatest(brandId, sortFilter, pageable)) { productCacheRepository.putFirstPage(result.getContent(), result.getTotalElements()); result.getContent().forEach(item -> - productCacheRepository.initLikeCount(item.id(), item.favoriteCnt())); + productCacheRepository.initLikeCountIfAbsent(item.id(), item.favoriteCnt())); } return result; @@ -81,7 +81,7 @@ public ProductItem findProductDetail(Long productId) { ProductItem item = productCustomRepository.findProduct(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); productCacheRepository.put(productId, item); - productCacheRepository.initLikeCount(productId, item.favoriteCnt()); + productCacheRepository.initLikeCountIfAbsent(productId, item.favoriteCnt()); return item; } @@ -141,7 +141,7 @@ private long resolveLikeCount(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); long count = product.getLikeCount(); - productCacheRepository.initLikeCount(productId, count); + productCacheRepository.initLikeCountIfAbsent(productId, count); return count; }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java index 0960fe5c3..1a29d2657 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/cache/ProductCacheRepositoryImpl.java @@ -63,8 +63,8 @@ public Optional getLikeCount(Long productId) { } @Override - public void initLikeCount(Long productId, long count) { - redisCacheManager.setCount(KEY_PREFIX + productId + LIKES_SUFFIX, count, TTL_SECONDS); + public void initLikeCountIfAbsent(Long productId, long count) { + redisCacheManager.setCountIfAbsent(KEY_PREFIX + productId + LIKES_SUFFIX, count, TTL_SECONDS); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java deleted file mode 100644 index 6e5b6c153..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/entity/ProductLikeStatsEntity.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.loopers.infrastructure.product.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.Getter; - -@Getter -@Entity -@Table(name = "product_like_stats") -public class ProductLikeStatsEntity { - - @Id - @Column(name = "product_id") - private Long productId; - - @Column(nullable = false) - private long likeCount; - - protected ProductLikeStatsEntity() {} - - public static ProductLikeStatsEntity create(Long productId) { - ProductLikeStatsEntity entity = new ProductLikeStatsEntity(); - entity.productId = productId; - entity.likeCount = 0; - return entity; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java deleted file mode 100644 index dd2730a13..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/ProductLikeStatsJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.infrastructure.product.repository; - -import com.loopers.infrastructure.product.entity.ProductLikeStatsEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -public interface ProductLikeStatsJpaRepository extends JpaRepository { - - @Modifying - @Query("UPDATE ProductLikeStatsEntity s SET s.likeCount = s.likeCount + 1 WHERE s.productId = :productId") - int increaseLikeCount(@Param("productId") Long productId); - - @Modifying - @Query("UPDATE ProductLikeStatsEntity s SET s.likeCount = s.likeCount - 1 WHERE s.productId = :productId AND s.likeCount > 0") - int decreaseLikeCount(@Param("productId") Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java index 01fcfd95a..31cf5485b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/repository/impl/ProductCustomRepositoryImpl.java @@ -45,7 +45,7 @@ public Page findProductList(Long brandId, SortFilter sortFilter, Pa .from(productEntity) .innerJoin(brandEntity).on(productEntity.brandId.eq(brandEntity.id)) .where(whereProductList(brandId)) - .orderBy(sortFilter.toOrderSpecifier(productEntity)) + .orderBy(sortFilter.toOrderSpecifiers(productEntity)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java index ad5dbb587..161248a63 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheManager.java @@ -95,4 +95,16 @@ public void setCount(String key, long value, long ttlSeconds) { log.warn("Redis SET count 실패 - key: {}", key, e); } } + + public void setCountIfAbsent(String key, long value, long ttlSeconds) { + try { + Boolean wasSet = writeTemplate.opsForValue() + .setIfAbsent(key, String.valueOf(value), ttlSeconds, TimeUnit.SECONDS); + if (Boolean.TRUE.equals(wasSet)) { + log.debug("Redis SETNX count 성공 - key: {}", key); + } + } catch (Exception e) { + log.warn("Redis SETNX count 실패 - key: {}", key, e); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java b/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java index b8f7bc5c1..028235862 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/enums/SortFilter.java @@ -8,11 +8,11 @@ public enum SortFilter { PRICE_ASC, LIKES_DESC; - public OrderSpecifier toOrderSpecifier(QProductEntity product) { + public OrderSpecifier[] toOrderSpecifiers(QProductEntity product) { return switch (this) { - case LATEST -> product.id.desc(); - case PRICE_ASC -> product.price.asc(); - case LIKES_DESC -> product.likeCount.desc(); + case LATEST -> new OrderSpecifier[]{ product.id.desc() }; + case PRICE_ASC -> new OrderSpecifier[]{ product.price.asc(), product.id.desc() }; + case LIKES_DESC -> new OrderSpecifier[]{ product.likeCount.desc(), product.id.desc() }; }; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index 73cd2d2e3..a34955762 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -45,7 +45,7 @@ private static Brand createTestBrand() { } private static Product createTestProduct(Long id, Long brandId) { - return Product.reconstruct(id, brandId, "상품A", 10000, 100, DisplayStatus.DISPLAYING); + return Product.reconstruct(id, brandId, "상품A", 10000, 100, DisplayStatus.DISPLAYING, 0L); } @DisplayName("브랜드 상세 조회") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java index 3c65f63fe..23bb9fee3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/favorite/FavoriteFacadeTest.java @@ -5,8 +5,8 @@ import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; import com.loopers.domain.product.model.Product; -import com.loopers.domain.product.service.ProductService; import com.loopers.domain.product.vo.DisplayStatus; +import com.loopers.domain.product.service.ProductService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -46,7 +46,7 @@ private static Member createTestMember() { } private static Product createTestProduct() { - return Product.reconstruct(1L, 1L, "상품A", 10000, 100, DisplayStatus.DISPLAYING); + return Product.reconstruct(1L, 1L, "상품A", 10000, 100, DisplayStatus.DISPLAYING, 0L); } @DisplayName("좋아요 등록") @@ -98,20 +98,22 @@ void throwsException_whenAlreadyFavorited() { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.CONFLICT)); } - @DisplayName("정상 등록 시 favoriteService.addFavorite이 호출된다") + @DisplayName("정상 등록 시 favoriteService.addFavorite과 productService.increaseLikeCount가 호출된다") @Test - void callsFavoriteServiceAddFavorite_onSuccess() { + void callsAddFavoriteAndIncreaseLikeCount_onSuccess() { // arrange Member member = createTestMember(); Product product = createTestProduct(); when(memberService.findMember("testuser", "password")).thenReturn(member); when(productService.findProduct(1L)).thenReturn(product); + when(favoriteService.addFavorite(any(FavoriteCommand.Add.class))).thenReturn(true); // act favoriteFacade.addFavorite("testuser", "password", 1L); // assert verify(favoriteService).addFavorite(any(FavoriteCommand.Add.class)); + verify(productService).increaseLikeCount(1L); } } @@ -149,9 +151,9 @@ void throwsException_whenFavoriteNotFound() { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } - @DisplayName("정상 취소 시 favoriteService.delete가 호출된다") + @DisplayName("정상 취소 시 favoriteService.delete와 productService.decreaseLikeCount가 호출된다") @Test - void callsFavoriteServiceDelete_onSuccess() { + void callsDeleteAndDecreaseLikeCount_onSuccess() { // arrange Member member = createTestMember(); Product product = createTestProduct(); @@ -163,6 +165,7 @@ void callsFavoriteServiceDelete_onSuccess() { // assert verify(favoriteService).delete(any(FavoriteCommand.Delete.class)); + verify(productService).decreaseLikeCount(1L); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 0d9dde711..a4eec203b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -11,6 +11,7 @@ import com.loopers.domain.order.service.OrderProductService; import com.loopers.domain.order.service.OrderService; import com.loopers.domain.product.model.Product; +import com.loopers.domain.coupon.service.CouponService; import com.loopers.domain.product.service.ProductService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -31,7 +32,6 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -53,12 +53,15 @@ class OrderFacadeTest { @Mock private ProductService productService; + @Mock + private CouponService couponService; + private static Member createTestMember() { return Member.reconstruct(1L, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); } private static Product createTestProduct(Long id, Long brandId, String name, int price, int stock) { - return Product.reconstruct(id, brandId, name, price, stock, DisplayStatus.DISPLAYING); + return Product.reconstruct(id, brandId, name, price, stock, DisplayStatus.DISPLAYING, 0L); } @DisplayName("주문 생성") @@ -101,7 +104,6 @@ void createsOrder_withMultipleProducts() { verify(productService).decreaseStockAtomic(1L, 2); verify(productService).decreaseStockAtomic(2L, 3); - // verify the command passed to orderService ArgumentCaptor captor = ArgumentCaptor.forClass(OrderCommand.Create.class); verify(orderService).createOrder(captor.capture()); OrderCommand.Create captured = captor.getValue(); 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 3eaf68895..3118bbdfc 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 @@ -1,5 +1,6 @@ package com.loopers.application.product; +import com.loopers.application.product.dto.FindProductListReqDto; import com.loopers.application.product.dto.FindProductListResDto; import com.loopers.application.product.dto.FindProductResDto; import com.loopers.domain.brand.model.Brand; @@ -7,8 +8,6 @@ import com.loopers.domain.favorite.service.FavoriteService; import com.loopers.domain.member.model.Member; import com.loopers.domain.member.service.MemberService; -import com.loopers.domain.product.model.Product; -import com.loopers.domain.product.vo.DisplayStatus; import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.service.ProductService; import com.loopers.support.enums.SortFilter; @@ -28,11 +27,13 @@ import java.time.LocalDate; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; @@ -61,14 +62,6 @@ private static Member createTestMember() { return Member.reconstruct(1L, "testuser", "encodedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); } - private static Brand createTestBrand() { - return Brand.reconstruct(1L, "나이키", "스포츠 브랜드"); - } - - private static Product createTestProduct() { - return Product.reconstruct(1L, 1L, "상품A", 10000, 100, DisplayStatus.DISPLAYING); - } - private static ProductItem createTestProductItem(boolean isFavorite) { return new ProductItem(1L, "상품A", 1L, "나이키", 10000, 100, "DISPLAYING", 5L, isFavorite); } @@ -86,24 +79,26 @@ void throwsException_whenBrandNotFound() { .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); // act & assert - assertThatThrownBy(() -> productFacade.findProductList("testuser", "password", 999L, SortFilter.LATEST, pageable)) + assertThatThrownBy(() -> productFacade.findProductList(new FindProductListReqDto("testuser", "password", 999L, SortFilter.LATEST), pageable)) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } - @DisplayName("비로그인 사용자(loginId=null)는 memberId=null로 상품 목록을 조회한다") + @DisplayName("비로그인 사용자의 상품 목록 조회 시 좋아요 여부는 모두 false이다") @Test - void findProductList_withNullMemberId_whenNotLoggedIn() { + void findProductList_withoutLogin() { // arrange Pageable pageable = PageRequest.of(0, 10); ProductItem item = createTestProductItem(false); Page itemPage = new PageImpl<>(List.of(item), pageable, 1); - when(productService.findProductList(isNull(), isNull(), any(SortFilter.class), eq(pageable))) + when(productService.findProductList(isNull(), any(SortFilter.class), eq(pageable))) .thenReturn(itemPage); + when(favoriteService.getFavoriteProductIds(isNull(), anyList())) + .thenReturn(Set.of()); // act - Page result = productFacade.findProductList(null, null, null, SortFilter.LATEST, pageable); + Page result = productFacade.findProductList(new FindProductListReqDto(null, null, null, SortFilter.LATEST), pageable); // assert assertAll( @@ -113,29 +108,51 @@ void findProductList_withNullMemberId_whenNotLoggedIn() { verify(memberService, never()).findMember(any(), any()); } - @DisplayName("정상 조회 시 상품 목록을 반환한다") + @DisplayName("로그인 사용자의 상품 목록 조회 시 좋아요 여부를 포함한다") @Test - void returnsProductList_onSuccess() { + void findProductList_withLogin_includesFavorite() { // arrange Pageable pageable = PageRequest.of(0, 10); Member member = createTestMember(); - ProductItem item = createTestProductItem(true); + ProductItem item = createTestProductItem(false); Page itemPage = new PageImpl<>(List.of(item), pageable, 1); when(memberService.findMember("testuser", "password")).thenReturn(member); - when(productService.findProductList(isNull(), eq(1L), any(SortFilter.class), eq(pageable))) + when(productService.findProductList(isNull(), any(SortFilter.class), eq(pageable))) .thenReturn(itemPage); + when(favoriteService.getFavoriteProductIds(eq(1L), anyList())) + .thenReturn(Set.of(1L)); // act - Page result = productFacade.findProductList("testuser", "password", null, SortFilter.LATEST, pageable); + Page result = productFacade.findProductList(new FindProductListReqDto("testuser", "password", null, SortFilter.LATEST), pageable); // assert assertAll( () -> assertThat(result.getTotalElements()).isEqualTo(1), - () -> assertThat(result.getContent().get(0).name()).isEqualTo("상품A"), () -> assertThat(result.getContent().get(0).isFavorite()).isTrue() ); } + + @DisplayName("brandId가 있으면 brandService로 브랜드 존재를 검증한다") + @Test + void validatesBrand_whenBrandIdPresent() { + // arrange + Pageable pageable = PageRequest.of(0, 10); + ProductItem item = createTestProductItem(false); + Page itemPage = new PageImpl<>(List.of(item), pageable, 1); + + when(brandService.findBrand(1L)).thenReturn(Brand.reconstruct(1L, "나이키", "스포츠")); + when(productService.findProductList(eq(1L), any(SortFilter.class), eq(pageable))) + .thenReturn(itemPage); + when(favoriteService.getFavoriteProductIds(isNull(), anyList())) + .thenReturn(Set.of()); + + // act + productFacade.findProductList(new FindProductListReqDto(null, null, 1L, SortFilter.LATEST), pageable); + + // assert + verify(brandService).findBrand(1L); + } } @DisplayName("상품 상세 조회") @@ -146,7 +163,7 @@ class FindProduct { @Test void throwsException_whenProductNotFound() { // arrange - when(productService.findProduct(999L)) + when(productService.findProductDetail(999L)) .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); // act & assert @@ -155,16 +172,12 @@ void throwsException_whenProductNotFound() { .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); } - @DisplayName("비로그인 사용자는 isFavorite=false로 상품 상세를 반환한다") + @DisplayName("비로그인 사용자의 상품 상세 조회 시 좋아요 여부는 false이다") @Test - void returnsProduct_withIsFavoriteFalse_whenNotLoggedIn() { + void returnsProduct_withoutLogin() { // arrange - Product product = createTestProduct(); - Brand brand = createTestBrand(); - - when(productService.findProduct(1L)).thenReturn(product); - when(brandService.findBrand(1L)).thenReturn(brand); - when(favoriteService.countByProductId(1L)).thenReturn(3L); + ProductItem item = createTestProductItem(false); + when(productService.findProductDetail(1L)).thenReturn(item); // act FindProductResDto result = productFacade.findProduct(null, null, 1L); @@ -172,24 +185,21 @@ void returnsProduct_withIsFavoriteFalse_whenNotLoggedIn() { // assert assertAll( () -> assertThat(result.id()).isEqualTo(1L), - () -> assertThat(result.favoriteCnt()).isEqualTo(3L), + () -> assertThat(result.favoriteCnt()).isEqualTo(5L), () -> assertThat(result.isFavorite()).isFalse() ); verify(favoriteService, never()).existsByMemberIdAndProductId(any(), any()); } - @DisplayName("로그인 사용자는 좋아요 수와 좋아요 여부를 포함한 상품 상세를 반환한다") + @DisplayName("로그인 사용자는 좋아요 여부를 포함한 상품 상세를 반환한다") @Test void returnsProduct_withFavoriteInfo_whenLoggedIn() { // arrange Member member = createTestMember(); - Product product = createTestProduct(); - Brand brand = createTestBrand(); + ProductItem item = createTestProductItem(false); + when(productService.findProductDetail(1L)).thenReturn(item); when(memberService.findMember("testuser", "password")).thenReturn(member); - when(productService.findProduct(1L)).thenReturn(product); - when(brandService.findBrand(1L)).thenReturn(brand); - when(favoriteService.countByProductId(1L)).thenReturn(5L); when(favoriteService.existsByMemberIdAndProductId(1L, 1L)).thenReturn(true); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index 2667e042a..0b3a3077f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -4,6 +4,7 @@ import com.loopers.domain.product.model.ProductCommand; import com.loopers.domain.product.model.ProductItem; import com.loopers.domain.product.vo.DisplayStatus; +import com.loopers.domain.product.repository.ProductCacheRepository; import com.loopers.domain.product.repository.ProductCustomRepository; import com.loopers.domain.product.repository.ProductRepository; import com.loopers.domain.product.service.ProductService; @@ -28,6 +29,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,6 +47,9 @@ class ProductServiceTest { @Mock private ProductCustomRepository productCustomRepository; + @Mock + private ProductCacheRepository productCacheRepository; + @DisplayName("상품 생성") @Nested class CreateProduct { @@ -52,7 +59,7 @@ class CreateProduct { void createsProduct_andReturnsSaved() { // arrange ProductCommand.Create command = new ProductCommand.Create(1L, "운동화", 50000, 100); - Product saved = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product saved = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); when(productRepository.save(any(Product.class))).thenReturn(saved); // act @@ -88,7 +95,7 @@ void throwsException_whenProductNotFound() { @Test void returnsProduct_whenFound() { // arrange - Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); // act @@ -109,8 +116,7 @@ class UpdateProduct { void throwsException_whenProductNotFound() { // arrange ProductCommand.Update command = new ProductCommand.Update( - "슬리퍼", 20000, 50, - com.loopers.domain.product.vo.DisplayStatus.DISPLAYING + "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING ); when(productRepository.findById(999L)).thenReturn(Optional.empty()); @@ -127,10 +133,9 @@ void throwsException_whenProductNotFound() { @Test void updatesProduct_andCallsUpdate() { // arrange - Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); ProductCommand.Update command = new ProductCommand.Update( - "슬리퍼", 20000, 50, - com.loopers.domain.product.vo.DisplayStatus.DISPLAYING + "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING ); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); @@ -167,7 +172,7 @@ void throwsException_whenProductNotFound() { @Test void deletesProduct_andCallsDeleteById() { // arrange - Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); // act @@ -186,7 +191,7 @@ class DecreaseStock { @Test void decreasesStock_andCallsUpdate() { // arrange - Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); // act productService.decreaseStock(product, 10); @@ -206,7 +211,7 @@ class GetProductsByIds { void throwsException_whenSomeIdsNotFound() { // arrange List ids = List.of(1L, 2L, 3L); - Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); + Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); when(productRepository.findByIds(ids)).thenReturn(List.of(product1)); // act & assert @@ -223,8 +228,8 @@ void throwsException_whenSomeIdsNotFound() { void returnsProducts_whenAllIdsFound() { // arrange List ids = List.of(1L, 2L); - Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); - Product product2 = Product.reconstruct(2L, 1L, "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING); + Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); + Product product2 = Product.reconstruct(2L, 1L, "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING, 0L); when(productRepository.findByIds(ids)).thenReturn(List.of(product1, product2)); // act @@ -245,8 +250,8 @@ class FindProductsByBrandId { @Test void returnsProducts_forGivenBrandId() { // arrange - Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING); - Product product2 = Product.reconstruct(2L, 1L, "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING); + Product product1 = Product.reconstruct(1L, 1L, "운동화", 50000, 100, DisplayStatus.DISPLAYING, 0L); + Product product2 = Product.reconstruct(2L, 1L, "슬리퍼", 20000, 50, DisplayStatus.DISPLAYING, 0L); Page page = new PageImpl<>(List.of(product1, product2)); when(productRepository.findAll(Pageable.unpaged(), 1L)).thenReturn(page); @@ -259,64 +264,177 @@ void returnsProducts_forGivenBrandId() { } } - @DisplayName("상품 목록 조회 (커스텀)") + @DisplayName("상품 목록 조회 (캐시 포함)") @Nested class FindProductList { - @DisplayName("정상적으로 상품 목록을 조회한다") + @DisplayName("브랜드 지정 시 캐시를 사용하지 않고 DB에서 조회한다") + @Test + void skipsCache_whenBrandIdPresent() { + // arrange + ProductItem item = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 5L, false); + Page page = new PageImpl<>(List.of(item)); + Pageable pageable = PageRequest.of(0, 10); + when(productCustomRepository.findProductList(1L, SortFilter.LATEST, pageable)).thenReturn(page); + + // act + Page result = productService.findProductList(1L, SortFilter.LATEST, pageable); + + // assert + verify(productCacheRepository, never()).getFirstPage(); + assertThat(result.getContent()).hasSize(1); + } + + @DisplayName("첫 페이지 캐시 히트 시 캐시에서 반환하고 likeCount를 resolve한다") @Test - void returnsProductItemPage_forGivenConditions() { + void returnsCachedPage_whenFirstPageCacheHit() { // arrange - ProductItem item1 = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 5L, false); - ProductItem item2 = new ProductItem(2L, "슬리퍼", 1L, "나이키", 20000, 50, "DISPLAYING", 2L, true); - Page page = new PageImpl<>(List.of(item1, item2)); Pageable pageable = PageRequest.of(0, 10); - when(productCustomRepository.findProductList(1L, 10L, SortFilter.LATEST, pageable)).thenReturn(page); + ProductItem cachedItem = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 0L, false); + ProductCacheRepository.CachedPage cached = new ProductCacheRepository.CachedPage(List.of(cachedItem), 100); + + when(productCacheRepository.getFirstPage()).thenReturn(Optional.of(cached)); + when(productCacheRepository.getLikeCount(1L)).thenReturn(Optional.of(5L)); // act - Page result = productService.findProductList(1L, 10L, SortFilter.LATEST, pageable); + Page result = productService.findProductList(null, SortFilter.LATEST, pageable); // assert - verify(productCustomRepository).findProductList(1L, 10L, SortFilter.LATEST, pageable); - assertThat(result.getContent()).hasSize(2); - assertThat(result.getContent().get(0).id()).isEqualTo(1L); - assertThat(result.getContent().get(1).id()).isEqualTo(2L); + verify(productCustomRepository, never()).findProductList(any(), any(), any()); + assertThat(result.getContent().get(0).favoriteCnt()).isEqualTo(5L); + assertThat(result.getTotalElements()).isEqualTo(100); + } + + @DisplayName("첫 페이지 캐시 미스 시 DB 조회 후 캐시에 저장한다") + @Test + void savesToCache_whenFirstPageCacheMiss() { + // arrange + Pageable pageable = PageRequest.of(0, 10); + ProductItem item = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 5L, false); + Page page = new PageImpl<>(List.of(item), pageable, 1); + + when(productCacheRepository.getFirstPage()).thenReturn(Optional.empty()); + when(productCustomRepository.findProductList(null, SortFilter.LATEST, pageable)).thenReturn(page); + + // act + productService.findProductList(null, SortFilter.LATEST, pageable); + + // assert + verify(productCacheRepository).putFirstPage(List.of(item), 1); + verify(productCacheRepository).initLikeCountIfAbsent(1L, 5L); } } - @DisplayName("상품 상세 조회 (회원 포함)") + @DisplayName("상품 상세 조회 (캐시 포함)") @Nested - class FindProductWithMemberId { + class FindProductDetail { + + @DisplayName("캐시 히트 시 캐시에서 반환하고 likeCount를 resolve한다") + @Test + void returnsCachedItem_whenCacheHit() { + // arrange + ProductItem cachedItem = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 0L, false); + when(productCacheRepository.get(1L)).thenReturn(Optional.of(cachedItem)); + when(productCacheRepository.getLikeCount(1L)).thenReturn(Optional.of(10L)); + + // act + ProductItem result = productService.findProductDetail(1L); + + // assert + assertThat(result.favoriteCnt()).isEqualTo(10L); + verify(productCustomRepository, never()).findProduct(anyLong()); + } + + @DisplayName("캐시 미스 시 DB 조회 후 캐시에 저장한다") + @Test + void savesToCache_whenCacheMiss() { + // arrange + ProductItem item = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 3L, false); + when(productCacheRepository.get(1L)).thenReturn(Optional.empty()); + when(productCustomRepository.findProduct(1L)).thenReturn(Optional.of(item)); + + // act + ProductItem result = productService.findProductDetail(1L); + + // assert + assertThat(result.favoriteCnt()).isEqualTo(3L); + verify(productCacheRepository).put(1L, item); + verify(productCacheRepository).initLikeCountIfAbsent(1L, 3L); + } @DisplayName("존재하지 않는 상품이면 예외가 발생한다") @Test void throwsException_whenProductNotFound() { // arrange - when(productCustomRepository.findProduct(999L, 10L)).thenReturn(Optional.empty()); + when(productCacheRepository.get(999L)).thenReturn(Optional.empty()); + when(productCustomRepository.findProduct(999L)).thenReturn(Optional.empty()); // act & assert - assertThatThrownBy(() -> productService.findProduct(999L, 10L)) + assertThatThrownBy(() -> productService.findProductDetail(999L)) .isInstanceOf(CoreException.class) - .satisfies(e -> { - CoreException ce = (CoreException) e; - assertThat(ce.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - }); + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.NOT_FOUND)); + } + } + + @DisplayName("좋아요 수 증감") + @Nested + class LikeCount { + + @DisplayName("increaseLikeCount는 DB와 캐시 카운터를 모두 증가시킨다") + @Test + void increaseLikeCount_updatesBothDbAndCache() { + // act + productService.increaseLikeCount(1L); + + // assert + verify(productRepository).increaseLikeCount(1L); + verify(productCacheRepository).incrementLikeCount(1L); + } + + @DisplayName("decreaseLikeCount는 DB와 캐시 카운터를 모두 감소시킨다") + @Test + void decreaseLikeCount_updatesBothDbAndCache() { + // act + productService.decreaseLikeCount(1L); + + // assert + verify(productRepository).decreaseLikeCount(1L); + verify(productCacheRepository).decrementLikeCount(1L); } + } + + @DisplayName("원자적 재고 차감") + @Nested + class DecreaseStockAtomic { - @DisplayName("정상적으로 상품 상세를 조회한다") + @DisplayName("정상 차감 시 캐시를 무효화한다") @Test - void returnsProductItem_whenFound() { + void evictsCache_onSuccess() { // arrange - ProductItem item = new ProductItem(1L, "운동화", 1L, "나이키", 50000, 100, "DISPLAYING", 3L, true); - when(productCustomRepository.findProduct(1L, 10L)).thenReturn(Optional.of(item)); + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 90, DisplayStatus.DISPLAYING, 0L); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.decreaseStock(1L, 10)).thenReturn(1); // act - ProductItem result = productService.findProduct(1L, 10L); + productService.decreaseStockAtomic(1L, 10); // assert - assertThat(result.id()).isEqualTo(1L); - assertThat(result.name()).isEqualTo("운동화"); - assertThat(result.isFavorite()).isTrue(); + verify(productCacheRepository).evict(1L); + verify(productCacheRepository).evictFirstPage(); + } + + @DisplayName("재고 부족 시 예외가 발생한다") + @Test + void throwsException_whenInsufficientStock() { + // arrange + Product product = Product.reconstruct(1L, 1L, "운동화", 50000, 5, DisplayStatus.DISPLAYING, 0L); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.decreaseStock(1L, 10)).thenReturn(0); + + // act & assert + assertThatThrownBy(() -> productService.decreaseStockAtomic(1L, 10)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 35dd173ec..1ff9baefd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -82,7 +82,7 @@ class Reconstruct { @Test void reconstructsProduct_withAllFields() { // act - Product product = Product.reconstruct(1L, 1L, "상품A", 10000, 50, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "상품A", 10000, 50, DisplayStatus.DISPLAYING, 0L); // assert assertAll( @@ -124,7 +124,7 @@ void updatesProduct_whenCommandIsValid() { @Test void throwsException_whenUpdateNameIsNull() { // arrange - Product product = Product.reconstruct(1L, 1L, "나이키 에어맥스", 150000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "나이키 에어맥스", 150000, 100, DisplayStatus.DISPLAYING, 0L); ProductCommand.Update updateCommand = new ProductCommand.Update(null, 20000, 30, DisplayStatus.NOT_DISPLAYING); // act & assert @@ -136,7 +136,7 @@ void throwsException_whenUpdateNameIsNull() { @Test void success_whenUpdatePriceIsZero() { // arrange - Product product = Product.reconstruct(1L, 1L, "나이키 에어맥스", 150000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "나이키 에어맥스", 150000, 100, DisplayStatus.DISPLAYING, 0L); ProductCommand.Update updateCommand = new ProductCommand.Update("상품", 0, 30, DisplayStatus.DISPLAYING); // act @@ -155,7 +155,7 @@ class DecreaseStock { @Test void decreasesStock_whenSufficientStock() { // arrange - Product product = Product.reconstruct(1L, 1L, "상품", 10000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "상품", 10000, 100, DisplayStatus.DISPLAYING, 0L); // act product.decreaseStock(30); @@ -168,7 +168,7 @@ void decreasesStock_whenSufficientStock() { @Test void throwsException_whenInsufficientStock() { // arrange - Product product = Product.reconstruct(1L, 1L, "상품", 10000, 10, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "상품", 10000, 10, DisplayStatus.DISPLAYING, 0L); // act & assert assertThatThrownBy(() -> product.decreaseStock(20)) @@ -184,7 +184,7 @@ class IncreaseStock { @Test void increasesStock() { // arrange - Product product = Product.reconstruct(1L, 1L, "상품", 10000, 100, DisplayStatus.DISPLAYING); + Product product = Product.reconstruct(1L, 1L, "상품", 10000, 100, DisplayStatus.DISPLAYING, 0L); // act product.increaseStock(50); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/cache/ProductCacheServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/cache/ProductCacheServiceTest.java new file mode 100644 index 000000000..e94a4880d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/product/cache/ProductCacheServiceTest.java @@ -0,0 +1,180 @@ +package com.loopers.infrastructure.product.cache; + +import com.loopers.domain.product.model.ProductItem; +import com.loopers.domain.product.repository.ProductCacheRepository; +import com.loopers.support.cache.RedisCacheManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +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.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductCacheRepositoryImplTest { + + @InjectMocks + private ProductCacheRepositoryImpl productCacheRepository; + + @Mock + private RedisCacheManager redisCacheManager; + + private static ProductCacheDto createTestCacheDto() { + return new ProductCacheDto(1L, "상품A", 1L, "나이키", 10000, 100, "DISPLAYING"); + } + + private static ProductItem createTestProductItem() { + return new ProductItem(1L, "상품A", 1L, "나이키", 10000, 100, "DISPLAYING", 5L, false); + } + + @DisplayName("상품 캐시 조회") + @Nested + class Get { + + @DisplayName("캐시에 데이터가 있으면 ProductItem으로 변환하여 반환한다") + @Test + void returnsProductItem_whenCacheHit() { + // arrange + ProductCacheDto dto = createTestCacheDto(); + when(redisCacheManager.get("product:1", ProductCacheDto.class)) + .thenReturn(Optional.of(dto)); + + // act + Optional result = productCacheRepository.get(1L); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().id()).isEqualTo(1L); + assertThat(result.get().name()).isEqualTo("상품A"); + assertThat(result.get().brandName()).isEqualTo("나이키"); + } + + @DisplayName("캐시에 데이터가 없으면 빈 Optional을 반환한다") + @Test + void returnsEmpty_whenCacheMiss() { + // arrange + when(redisCacheManager.get("product:1", ProductCacheDto.class)) + .thenReturn(Optional.empty()); + + // act + Optional result = productCacheRepository.get(1L); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("상품 캐시 저장") + @Nested + class Put { + + @DisplayName("ProductItem을 ProductCacheDto로 변환하여 Redis에 저장한다") + @Test + void convertsAndSaves() { + // arrange + ProductItem item = createTestProductItem(); + + // act + productCacheRepository.put(1L, item); + + // assert + verify(redisCacheManager).put(eq("product:1"), any(ProductCacheDto.class), eq(3600L)); + } + } + + @DisplayName("상품 캐시 삭제") + @Nested + class Evict { + + @DisplayName("RedisCacheManager에 삭제를 위임한다") + @Test + void delegatesToRedisCacheManager() { + // act + productCacheRepository.evict(1L); + + // assert + verify(redisCacheManager).evict("product:1"); + } + } + + @DisplayName("첫 페이지 캐시") + @Nested + class FirstPage { + + @DisplayName("캐시 히트 시 CachedPage로 변환하여 반환한다") + @Test + void returnsCachedPage_whenHit() { + // arrange + ProductCacheDto dto = createTestCacheDto(); + ProductListCacheDto listDto = new ProductListCacheDto(List.of(dto), 100); + when(redisCacheManager.get("product-list:first-page", ProductListCacheDto.class)) + .thenReturn(Optional.of(listDto)); + + // act + Optional result = productCacheRepository.getFirstPage(); + + // assert + assertThat(result).isPresent(); + assertThat(result.get().items()).hasSize(1); + assertThat(result.get().totalElements()).isEqualTo(100); + } + + @DisplayName("ProductItem 리스트를 ProductCacheDto로 변환하여 저장한다") + @Test + void convertsAndSavesFirstPage() { + // arrange + ProductItem item = createTestProductItem(); + + // act + productCacheRepository.putFirstPage(List.of(item), 100); + + // assert + verify(redisCacheManager).put(eq("product-list:first-page"), any(ProductListCacheDto.class), eq(300L)); + } + } + + @DisplayName("좋아요 카운터") + @Nested + class LikeCount { + + @DisplayName("카운터 초기화 시 Redis에 값을 설정한다") + @Test + void initSetsCount() { + // act + productCacheRepository.initLikeCountIfAbsent(1L, 5); + + // assert + verify(redisCacheManager).setCountIfAbsent("product:1:likes", 5, 3600); + } + + @DisplayName("카운터 증가 시 Redis increment를 호출한다") + @Test + void incrementDelegatesToRedis() { + // act + productCacheRepository.incrementLikeCount(1L); + + // assert + verify(redisCacheManager).increment("product:1:likes"); + } + + @DisplayName("카운터 감소 시 Redis decrement를 호출한다") + @Test + void decrementDelegatesToRedis() { + // act + productCacheRepository.decrementLikeCount(1L); + + // assert + verify(redisCacheManager).decrement("product:1:likes"); + } + } +}