diff --git a/.gitignore b/.gitignore index 1f9a02e14..962b6e14c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,10 +39,11 @@ out/ ### Kotlin ### .kotlin -### QueryDSL Generated ### -**/src/main/generated/ +### macOS ### +.DS_Store ### Claude Code ### .claude/ - +### QueryDSL Generated ### +**/generated/ diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 6acd86062..1cd42952e 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -14,6 +14,10 @@ dependencies { // security implementation("org.springframework.security:spring-security-crypto") + // cache + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("com.github.ben-manes.caffeine:caffeine") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java index 601d317ea..95cc564c0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/AdminProductFacade.java @@ -6,6 +6,7 @@ import com.loopers.domain.product.AdminProductSearchCondition; import com.loopers.domain.product.MarginType; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; import com.loopers.domain.product.ProductOption; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductStatus; @@ -25,6 +26,7 @@ public class AdminProductFacade { private final ProductService productService; private final BrandService brandService; private final CartService cartService; + private final ProductCacheStore productCacheStore; public Page getProducts(AdminProductSearchCondition condition, Pageable pageable) { return productService.adminSearch(condition, pageable).map(ProductInfo::from); @@ -52,6 +54,9 @@ public ProductDetailInfo updateProduct(Long productId, String name, int price, i List options) { Product product = productService.update(productId, name, price, supplyPrice, discountPrice, shippingFee, description, status, displayYn, options); + + // 상품 수정 시 상세 캐시를 명시적으로 삭제 -> 삭제 후 최초 사용자가 조회시 캐싱. + productCacheStore.evictDetail(productId); return ProductDetailInfo.from(product); } @@ -60,5 +65,8 @@ public void deleteProduct(Long productId) { List optionIds = productService.findOptionIdsByProductId(productId); cartService.deleteByProductOptionIds(optionIds); productService.softDelete(productId); + + // 삭제된 상품의 상세 캐시를 제거하여 삭제 후에도 캐시된 데이터가 반환되지 않도록 한다. + productCacheStore.evictDetail(productId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 5e0326556..450ff3017 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,39 +2,116 @@ import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -@RequiredArgsConstructor @Component @Transactional(readOnly = true) public class ProductFacade { private final ProductService productService; private final LikeService likeService; + private final ProductCacheStore redisCacheStore; + private final ProductCacheStore localCacheStore; + public ProductFacade(ProductService productService, + LikeService likeService, + @Qualifier("productRedisCacheStore") ProductCacheStore redisCacheStore, + @Qualifier("productLocalCacheStore") ProductCacheStore localCacheStore) { + this.productService = productService; + this.likeService = likeService; + this.redisCacheStore = redisCacheStore; + this.localCacheStore = localCacheStore; + } + + /** + * 상품 목록 조회 - Redis Cache-Aside 적용. + */ public Page getProducts(ProductSearchCondition condition, Pageable pageable) { - return productService.search(condition, pageable).map(ProductInfo::from); + Page products = redisCacheStore.getSearch(condition, pageable) + .orElseGet(() -> { + Page fromDb = productService.search(condition, pageable); + redisCacheStore.setSearch(condition, pageable, fromDb); + return fromDb; + }); + return products.map(ProductInfo::from); } + /** + * 상품 목록 조회 - 로컬 캐시 Cache-Aside 적용. + */ + public Page getProductsWithLocalCache(ProductSearchCondition condition, Pageable pageable) { + Page products = localCacheStore.getSearch(condition, pageable) + .orElseGet(() -> { + Page fromDb = productService.search(condition, pageable); + localCacheStore.setSearch(condition, pageable, fromDb); + return fromDb; + }); + return products.map(ProductInfo::from); + } + + /** + * 상품 상세 조회 - Redis Cache-Aside 적용. + */ public ProductDetailInfo getProduct(Long productId) { + Product product = redisCacheStore.getDetail(productId) + .orElseGet(() -> { + Product fromDb = productService.findById(productId); + redisCacheStore.setDetail(productId, fromDb); + return fromDb; + }); + return ProductDetailInfo.from(product); + } + + /** + * 상품 상세 조회 - 로컬 캐시 Cache-Aside 적용. + */ + public ProductDetailInfo getProductWithLocalCache(Long productId) { + Product product = localCacheStore.getDetail(productId) + .orElseGet(() -> { + Product fromDb = productService.findById(productId); + localCacheStore.setDetail(productId, fromDb); + return fromDb; + }); + return ProductDetailInfo.from(product); + } + + /** + * 상품 목록 조회 - 캐시 미적용 (DB 직접 조회) + */ + public Page getProductsNoCache(ProductSearchCondition condition, Pageable pageable) { + return productService.search(condition, pageable).map(ProductInfo::from); + } + + /** + * 상품 상세 조회 - 캐시 미적용 (DB 직접 조회) + */ + public ProductDetailInfo getProductNoCache(Long productId) { Product product = productService.findById(productId); return ProductDetailInfo.from(product); } @Transactional public void like(Long memberId, Long productId) { - likeService.like(memberId, productId); + productService.findById(productId); + boolean changed = likeService.like(memberId, productId); + if (changed) { + productService.incrementLikeCount(productId); + } } @Transactional public void unlike(Long memberId, Long productId) { - likeService.unlike(memberId, productId); + boolean changed = likeService.unlike(memberId, productId); + if (changed) { + productService.decrementLikeCount(productId); + } } public Page getLikedProducts(Long memberId, Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/config/LocalCacheConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/LocalCacheConfig.java new file mode 100644 index 000000000..808deea53 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/LocalCacheConfig.java @@ -0,0 +1,42 @@ +package com.loopers.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.List; + +@EnableCaching +@Configuration +public class LocalCacheConfig { + + /** + * 로컬 캐시 사용 + * @return + */ + @Bean + public CacheManager cacheManager() { + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(List.of( + buildCache("productSearch", Duration.ofMinutes(3), 1_000), // 상품 목록 조회용 + buildCache("productDetail", Duration.ofMinutes(5), 5_000) // 상품 상세 조회용 + + // TTL의 경우, + // maxSize의 경우, 여러 개의 상품 상세 정보를 캐시하기 위해 상품 리스트 보다 상품 상세가 양이 더 많게 설정했음. + )); + return cacheManager; + } + + private CaffeineCache buildCache(String name, Duration ttl, long maxSize) { + return new CaffeineCache(name, Caffeine.newBuilder() + .expireAfterWrite(ttl) + .maximumSize(maxSize) + .recordStats() + .build()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/ProductCacheWarmUp.java b/apps/commerce-api/src/main/java/com/loopers/config/ProductCacheWarmUp.java new file mode 100644 index 000000000..a4edb7c1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/ProductCacheWarmUp.java @@ -0,0 +1,84 @@ +package com.loopers.config; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +/** + * 애플리케이션 기동 시 자주 조회되는 상품 데이터를 캐시에 미리 적재한다. + * + * - 상품 목록: 기본 정렬(LATEST) 0~2페이지 + * - 상품 상세: 목록 첫 페이지에 노출된 상품들의 상세 정보 + * + * Cache-Aside 로직은 ProductFacade가 담당하므로, + * 웜업은 Facade 메서드를 호출하여 자연스럽게 캐시를 적재한다. + * 웜업 실패 시에도 서비스 기동에는 영향을 주지 않는다. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class ProductCacheWarmUp { + + private static final int WARM_UP_MAX_PAGE = 2; + private static final int DEFAULT_PAGE_SIZE = 20; + + private final ProductFacade productFacade; + + @EventListener(ApplicationReadyEvent.class) + public void warmUp() { + log.info("[CacheWarmUp] 상품 캐시 웜업 시작"); + + int listCount = warmUpSearchCache(); + int detailCount = warmUpDetailCache(); + + log.info("[CacheWarmUp] 상품 캐시 웜업 완료 - 목록 {}페이지, 상세 {}건", listCount, detailCount); + } + + private int warmUpSearchCache() { + ProductSearchCondition defaultCondition = ProductSearchCondition.of(null, ProductSortType.LATEST, null); + int warmedPages = 0; + + for (int page = 0; page <= WARM_UP_MAX_PAGE; page++) { + try { + Pageable pageable = PageRequest.of(page, DEFAULT_PAGE_SIZE); + productFacade.getProducts(defaultCondition, pageable); + warmedPages++; + } catch (Exception e) { + log.warn("[CacheWarmUp] 상품 목록 캐시 웜업 실패: page={}", page, e); + } + } + return warmedPages; + } + + private int warmUpDetailCache() { + ProductSearchCondition defaultCondition = ProductSearchCondition.of(null, ProductSortType.LATEST, null); + int warmedCount = 0; + + try { + Pageable pageable = PageRequest.of(0, DEFAULT_PAGE_SIZE); + Page products = productFacade.getProducts(defaultCondition, pageable); + + for (ProductInfo productInfo : products.getContent()) { + try { + productFacade.getProduct(productInfo.id()); + warmedCount++; + } catch (Exception e) { + log.warn("[CacheWarmUp] 상품 상세 캐시 웜업 실패: productId={}", productInfo.id(), e); + } + } + } catch (Exception e) { + log.warn("[CacheWarmUp] 상품 상세 캐시 웜업 대상 조회 실패", e); + } + return warmedCount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 9c84c629e..0530c7ab3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -29,7 +29,11 @@ public void addInterceptors(InterceptorRegistry registry) { "/api/v1/members/signup", "/api/v1/brands/**", "/api/v1/products", + "/api/v1/products/local-cache", "/api/v1/products/{id}", + "/api/v1/products/{id}/local-cache", + "/api/v1/products/no-cache", + "/api/v1/products/{id}/no-cache", "/api/v1/examples/**" ); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 8d32e2902..2c120f55b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -57,4 +57,14 @@ public void changeStatus(BrandStatus status) { public boolean isActive() { return this.status == BrandStatus.ACTIVE; } + + /** + * 캐시 복원용 팩토리 메서드. + * 캐시에 저장된 최소한의 브랜드 정보로 도메인 객체를 복원한다. + */ + public static Brand restoreFromCache(Long id, String name) { + Brand brand = new Brand(name, ""); + brand.restoreBase(id, null); + return brand; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index b0e3b0342..6e306f669 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -13,4 +13,6 @@ public interface LikeRepository { Page findLikedProductsByMemberId(Long memberId, Pageable pageable); Like save(Like like); + + void refreshLikeSummary(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index b172f16c4..e92ae8faf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,7 +1,6 @@ package com.loopers.domain.like; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -11,20 +10,26 @@ import org.springframework.stereotype.Component; import java.util.Optional; +/** + * 좋아요 도메인 서비스. + * Like 엔티티의 상태 전환(생성, 활성화, 비활성화)만 담당한다. + * 상품 존재 여부 확인, likeCount 증감 등 타 도메인 연동은 상위 레이어(Facade)에서 처리한다. + * 반드시 상위 레이어(@Transactional)의 트랜잭션 내에서 호출되어야 한다. + */ @Slf4j @RequiredArgsConstructor @Component public class LikeService { private final LikeRepository likeRepository; - private final ProductRepository productRepository; - public Like like(Long memberId, Long productId) { - // 1. 상품 존재 여부 확인 - productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); - - // 2. 기존 좋아요 조회 + /** + * 좋아요를 등록한다. + * 이미 좋아요 상태면 멱등하게 동작하며, 실제 likeCount 변경이 필요한지를 반환한다. + * + * @return 실제로 좋아요 상태가 변경(신규 생성 또는 N→Y 전환)되었으면 true, 이미 좋아요 상태면 false + */ + public boolean like(Long memberId, Long productId) { Optional existingLike = likeRepository.findByMemberIdAndProductId(memberId, productId); if (existingLike.isPresent()) { @@ -32,49 +37,53 @@ public Like like(Long memberId, Long productId) { // 이미 좋아요 상태면 아무것도 하지 않음 (멱등성) if (like.isLiked()) { - return like; + return false; } // N → Y 전환 like.like(); - Like savedLike = likeRepository.save(like); - productRepository.incrementLikeCount(productId); - return savedLike; + likeRepository.save(like); + return true; } - // 3. 신규 좋아요 생성 + // 신규 좋아요 생성 // 동시 요청에 의한 UniqueConstraint 위반은 DataIntegrityViolationException으로 발생하며, // ApiControllerAdvice에서 409 CONFLICT로 처리된다. Like newLike = Like.create(memberId, productId); - Like savedLike = likeRepository.save(newLike); - productRepository.incrementLikeCount(productId); - return savedLike; + likeRepository.save(newLike); + return true; } - public Like unlike(Long memberId, Long productId) { - // 1. 기존 좋아요 조회 + /** + * 좋아요를 취소한다. + * 이미 취소 상태면 멱등하게 동작하며, 실제 likeCount 변경이 필요한지를 반환한다. + * + * @return 실제로 취소 상태로 변경(Y→N 전환)되었으면 true, 이미 취소 상태면 false + */ + public boolean unlike(Long memberId, Long productId) { Like like = likeRepository.findByMemberIdAndProductId(memberId, productId) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "좋아요 기록이 존재하지 않습니다.")); // 이미 좋아요 취소 상태면 아무것도 하지 않음 (멱등성) if (!like.isLiked()) { - return like; + return false; } - // 2. Y → N 전환 + // Y → N 전환 like.unlike(); - Like savedLike = likeRepository.save(like); - - // 3. 상품 좋아요 수 감소 (Atomic UPDATE) - int updatedRows = productRepository.decrementLikeCount(productId); - if (updatedRows == 0) { - log.warn("좋아요 수 감소 실패: productId={}, likeCount가 이미 0입니다.", productId); - } - - return savedLike; + likeRepository.save(like); + return true; } public Page getLikedProducts(Long memberId, Pageable pageable) { return likeRepository.findLikedProductsByMemberId(memberId, pageable); } + + /** + * Like 테이블의 집계 결과를 product_like_summary 테이블에 갱신한다. + * Materialized View 방식에서 주기적(배치/스케줄러)으로 호출된다. + */ + public void refreshLikeSummary() { + likeRepository.refreshLikeSummary(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeSummary.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeSummary.java new file mode 100644 index 000000000..2033e257d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeSummary.java @@ -0,0 +1,33 @@ +package com.loopers.domain.like; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "product_like_summary") +public class ProductLikeSummary { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected ProductLikeSummary() {} + + public ProductLikeSummary(Long productId, int likeCount) { + this.productId = productId; + this.likeCount = likeCount; + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 9b541c347..3de2bc6fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -5,7 +5,9 @@ import com.loopers.domain.product.ProductOption; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -17,7 +19,8 @@ public class OrderItem extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "order_id", nullable = false) + @JoinColumn(name = "order_id", nullable = false, + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Order order; @Column(name = "product_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index a1f3f6d79..05bdd75bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -6,23 +6,33 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Transient; import lombok.Getter; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @Getter @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_id", columnList = "brand_id"), + @Index(name = "idx_product_status_display_like", columnList = "status, display_yn, like_count"), + @Index(name = "idx_product_status_display_created", columnList = "status, display_yn, created_at"), + @Index(name = "idx_product_status_display_price", columnList = "status, display_yn, price") +}) public class Product extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "brand_id", nullable = false) + @JoinColumn(name = "brand_id", nullable = false, + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Brand brand; @Column(name = "name", nullable = false) @@ -95,6 +105,21 @@ public static int calculateSupplyPrice(int price, MarginType marginType, int mar }; } + /** + * 캐시 복원용 팩토리 메서드. + * Infrastructure 레이어에서 Reflection 없이 도메인 객체를 복원할 수 있도록 한다. + */ + public static Product restoreFromCache(Long id, Brand brand, String name, int price, int supplyPrice, + int discountPrice, int shippingFee, int likeCount, + String description, MarginType marginType, ProductStatus status, + String displayYn, List options, ZonedDateTime createdAt) { + Product product = new Product(brand, name, price, supplyPrice, discountPrice, + shippingFee, description, marginType, status, displayYn, options); + product.likeCount = likeCount; + product.restoreBase(id, createdAt); + return product; + } + public void updateInfo(String name, int price, int supplyPrice, int discountPrice, int shippingFee, String description, ProductStatus status, String displayYn, List options) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheStore.java new file mode 100644 index 000000000..6df7ef33a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCacheStore.java @@ -0,0 +1,32 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * 상품 캐시 저장소 Port. + * Domain 레이어에서 캐시 조회/저장/삭제를 추상화하며, + * 구체적인 캐시 기술(Redis, Caffeine 등)은 Infrastructure 구현체가 결정한다. + */ +public interface ProductCacheStore { + + Optional getDetail(Long productId); + + void setDetail(Long productId, Product product); + + void evictDetail(Long productId); + + /** + * 검색 조건에 해당하는 캐시를 조회한다. + * 캐싱 대상이 아닌 페이지이거나 캐시 미스인 경우 Optional.empty()를 반환한다. + */ + Optional> getSearch(ProductSearchCondition condition, Pageable pageable); + + /** + * 검색 결과를 캐시에 저장한다. + * 캐싱 대상이 아닌 페이지인 경우 저장하지 않는다. + */ + void setSearch(ProductSearchCondition condition, Pageable pageable, Page products); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java index 693913b81..c88e51b6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductOption.java @@ -50,4 +50,13 @@ public void restoreStock(int quantity) { } this.stockQuantity += quantity; } + + /** + * 캐시 복원용 팩토리 메서드. + */ + public static ProductOption restoreFromCache(Long id, Long productId, String optionName, int stockQuantity) { + ProductOption option = new ProductOption(productId, optionName, stockQuantity); + option.restoreBase(id, null); + return option; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index df939ff25..399822c41 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -24,6 +24,12 @@ public interface ProductRepository { Page search(ProductSearchCondition condition, Pageable pageable); + /** + * Materialized View(product_like_summary) 기반 상품 검색. + * 좋아요 수를 summary 테이블 JOIN으로 조회하며, 좋아요순 정렬 시 summary 테이블의 like_count를 사용한다. + */ + Page searchWithMaterializedView(ProductSearchCondition condition, Pageable pageable); + Page adminSearch(AdminProductSearchCondition condition, Pageable pageable); void softDelete(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 25122da17..1b4b27db1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -4,12 +4,14 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.util.List; +@Slf4j @RequiredArgsConstructor @Component public class ProductService { @@ -50,10 +52,23 @@ public ProductOption findOptionById(Long optionId) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품 옵션을 찾을 수 없습니다.")); } + /** + * 비정규화(Product.likeCount) 기반 상품 검색. + * 좋아요순 정렬 시 Product 테이블의 like_count 컬럼을 직접 사용한다. + */ public Page search(ProductSearchCondition condition, Pageable pageable) { return productRepository.search(condition, pageable); } + /** + * [TEST용] Materialized View(product_like_summary) 기반 상품 검색. + * 좋아요순 정렬 시 summary 테이블의 like_count를 LEFT JOIN으로 조회한다. + * 비정규화 방식 대비 Product 테이블에 쓰기 부하가 없으나, 갱신 주기만큼 지연이 발생한다. + */ + public Page searchWithMaterializedView(ProductSearchCondition condition, Pageable pageable) { + return productRepository.searchWithMaterializedView(condition, pageable); + } + public Page adminSearch(AdminProductSearchCondition condition, Pageable pageable) { return productRepository.adminSearch(condition, pageable); } @@ -90,6 +105,17 @@ public List findOptionIdsByBrandId(Long brandId) { return productRepository.findOptionIdsByBrandId(brandId); } + public void incrementLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + public void decrementLikeCount(Long productId) { + int updatedRows = productRepository.decrementLikeCount(productId); + if (updatedRows == 0) { + log.warn("좋아요 수 감소 실패: productId={}, likeCount가 이미 0입니다.", productId); + } + } + /** * 재고를 차감하고 차감된 옵션을 반환한다. * 반드시 상위 레이어(@Transactional)의 트랜잭션 내에서 호출되어야 한다. diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 592746749..4115f20e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -26,6 +26,7 @@ public class LikeRepositoryImpl implements LikeRepository { private final LikeJpaRepository likeJpaRepository; + private final ProductLikeSummaryJpaRepository productLikeSummaryJpaRepository; private final ProductOptionJpaRepository productOptionJpaRepository; private final JPAQueryFactory queryFactory; @@ -80,4 +81,9 @@ public Page findLikedProductsByMemberId(Long memberId, Pageable pageabl public Like save(Like like) { return likeJpaRepository.save(like); } + + @Override + public void refreshLikeSummary() { + productLikeSummaryJpaRepository.refreshAll(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeSummaryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeSummaryJpaRepository.java new file mode 100644 index 000000000..f6b149345 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeSummaryJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeSummary; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface ProductLikeSummaryJpaRepository extends JpaRepository { + + @Modifying + @Query(value = """ + REPLACE INTO product_like_summary (product_id, like_count, updated_at) + SELECT l.product_id, COUNT(*), NOW() + FROM likes l + WHERE l.like_yn = 'Y' + GROUP BY l.product_id + """, nativeQuery = true) + void refreshAll(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheStore.java new file mode 100644 index 000000000..2e37c1f85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductLocalCacheStore.java @@ -0,0 +1,82 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; +import com.loopers.domain.product.ProductSearchCondition; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.Optional; + +/** + * Caffeine 기반 로컬 캐시 저장소 구현체. + * Spring CacheManager를 통해 로컬 캐시를 조회/저장/삭제한다. + */ +@Slf4j +@Component +public class ProductLocalCacheStore implements ProductCacheStore { + + private static final int MAX_CACHEABLE_PAGE = 2; + + private final Cache productDetailCache; + private final Cache productSearchCache; + + public ProductLocalCacheStore(CacheManager cacheManager) { + this.productDetailCache = Objects.requireNonNull( + cacheManager.getCache("productDetail"), "productDetail cache must be configured"); + this.productSearchCache = Objects.requireNonNull( + cacheManager.getCache("productSearch"), "productSearch cache must be configured"); + } + + @Override + public Optional getDetail(Long productId) { + Product cached = productDetailCache.get(productId, Product.class); + return Optional.ofNullable(cached); + } + + @Override + public void setDetail(Long productId, Product product) { + productDetailCache.put(productId, product); + } + + @Override + public void evictDetail(Long productId) { + productDetailCache.evict(productId); + } + + @Override + @SuppressWarnings("unchecked") + public Optional> getSearch(ProductSearchCondition condition, Pageable pageable) { + if (!isCacheablePage(pageable.getPageNumber())) { + return Optional.empty(); + } + String key = buildSearchKey(condition, pageable); + Page cached = productSearchCache.get(key, Page.class); + return Optional.ofNullable(cached); + } + + @Override + public void setSearch(ProductSearchCondition condition, Pageable pageable, Page products) { + if (!isCacheablePage(pageable.getPageNumber())) { + return; + } + String key = buildSearchKey(condition, pageable); + productSearchCache.put(key, products); + } + + private boolean isCacheablePage(int pageNumber) { + return pageNumber <= MAX_CACHEABLE_PAGE; + } + + private String buildSearchKey(ProductSearchCondition condition, Pageable pageable) { + String keywordPart = condition.keyword() != null ? condition.keyword() : "all"; + String brandPart = condition.brandId() != null ? condition.brandId().toString() : "all"; + return keywordPart + "_" + condition.sort().name() + + "_" + brandPart + "_" + pageable.getPageNumber() + "_" + pageable.getPageSize(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRedisCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRedisCacheStore.java new file mode 100644 index 000000000..b7e9d3652 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRedisCacheStore.java @@ -0,0 +1,190 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Redis 기반 상품 캐시 저장소 구현체. + * + * - 모든 Redis 호출을 try-catch로 감싸서, Redis 장애 시에도 DB fallback으로 서비스가 정상 동작한다. + * - Domain 객체(Product)를 내부 직렬화 record로 변환하여 JSON으로 저장하고, + * 조회 시 다시 Domain 객체로 복원한다. + */ +@Slf4j +@RequiredArgsConstructor +@Primary +@Component +public class ProductRedisCacheStore implements ProductCacheStore { + + private static final String DETAIL_KEY_PREFIX = "product:detail:"; + private static final String SEARCH_KEY_PREFIX = "product:search:"; + private static final Duration DETAIL_TTL = Duration.ofMinutes(10); + private static final Duration SEARCH_TTL = Duration.ofMinutes(3); + private static final int MAX_CACHEABLE_PAGE = 2; + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // ==================== 상품 상세 캐시 ==================== + + @Override + public Optional getDetail(Long productId) { + try { + String json = redisTemplate.opsForValue().get(detailKey(productId)); + if (json == null) { + return Optional.empty(); + } + CachedProductData data = objectMapper.readValue(json, CachedProductData.class); + return Optional.of(data.toProduct()); + } catch (Exception e) { + log.warn("Redis 상품 상세 조회 실패: productId={}", productId, e); + return Optional.empty(); + } + } + + @Override + public void setDetail(Long productId, Product product) { + try { + String json = objectMapper.writeValueAsString(CachedProductData.from(product)); + redisTemplate.opsForValue().set(detailKey(productId), json, DETAIL_TTL); + } catch (Exception e) { + log.warn("Redis 상품 상세 저장 실패: productId={}", productId, e); + } + } + + @Override + public void evictDetail(Long productId) { + try { + redisTemplate.delete(detailKey(productId)); + } catch (Exception e) { + log.warn("Redis 상품 상세 캐시 삭제 실패: productId={}", productId, e); + } + } + + // ==================== 상품 목록 캐시 ==================== + + @Override + public Optional> getSearch(ProductSearchCondition condition, Pageable pageable) { + if (!isCacheablePage(pageable.getPageNumber())) { + return Optional.empty(); + } + try { + String cacheKey = buildSearchKey(condition, pageable); + String json = redisTemplate.opsForValue().get(cacheKey); + if (json == null) { + return Optional.empty(); + } + CachedSearchPage cached = objectMapper.readValue(json, CachedSearchPage.class); + List products = cached.content().stream() + .map(CachedProductData::toProduct) + .toList(); + return Optional.of(new PageImpl<>(products, pageable, cached.totalElements())); + } catch (Exception e) { + log.warn("Redis 상품 목록 조회 실패: condition={}", condition, e); + return Optional.empty(); + } + } + + @Override + public void setSearch(ProductSearchCondition condition, Pageable pageable, Page products) { + if (!isCacheablePage(pageable.getPageNumber())) { + return; + } + try { + String cacheKey = buildSearchKey(condition, pageable); + List content = products.getContent().stream() + .map(CachedProductData::from) + .toList(); + String json = objectMapper.writeValueAsString(new CachedSearchPage(content, products.getTotalElements())); + redisTemplate.opsForValue().set(cacheKey, json, SEARCH_TTL); + } catch (Exception e) { + log.warn("Redis 상품 목록 저장 실패: condition={}", condition, e); + } + } + + // ==================== 내부 유틸리티 ==================== + + private boolean isCacheablePage(int pageNumber) { + return pageNumber <= MAX_CACHEABLE_PAGE; + } + + private String buildSearchKey(ProductSearchCondition condition, Pageable pageable) { + String keywordPart = condition.keyword() != null ? condition.keyword() : "all"; + String brandPart = condition.brandId() != null ? condition.brandId().toString() : "all"; + return SEARCH_KEY_PREFIX + keywordPart + ":" + condition.sort().name() + + ":" + brandPart + ":" + pageable.getPageNumber() + ":" + pageable.getPageSize(); + } + + private String detailKey(Long productId) { + return DETAIL_KEY_PREFIX + productId; + } + + // ==================== 직렬화/역직렬화용 내부 record ==================== + + record CachedProductData( + Long id, Long brandId, String brandName, + String name, int price, int supplyPrice, int discountPrice, + int shippingFee, int likeCount, String description, + MarginType marginType, ProductStatus status, String displayYn, + List options, ZonedDateTime createdAt + ) { + static CachedProductData from(Product product) { + List cachedOptions = product.getOptions().stream() + .map(CachedOptionData::from) + .toList(); + return new CachedProductData( + product.getId(), product.getBrand().getId(), product.getBrand().getName(), + product.getName(), product.getPrice(), product.getSupplyPrice(), product.getDiscountPrice(), + product.getShippingFee(), product.getLikeCount(), product.getDescription(), + product.getMarginType(), product.getStatus(), product.getDisplayYn(), + cachedOptions, product.getCreatedAt() + ); + } + + Product toProduct() { + Brand brand = Brand.restoreFromCache(brandId, brandName); + + List productOptions = options.stream() + .map(CachedOptionData::toProductOption) + .toList(); + + return Product.restoreFromCache(id, brand, name, price, supplyPrice, discountPrice, + shippingFee, likeCount, description, marginType, status, displayYn, + productOptions, createdAt); + } + } + + record CachedOptionData(Long id, Long productId, String optionName, int stockQuantity) { + static CachedOptionData from(ProductOption option) { + return new CachedOptionData(option.getId(), option.getProductId(), + option.getOptionName(), option.getStockQuantity()); + } + + ProductOption toProductOption() { + return ProductOption.restoreFromCache(id, productId, optionName, stockQuantity); + } + } + + record CachedSearchPage(List content, long totalElements) { + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 7a305befd..354c574d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -6,8 +6,10 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductStatus; +import com.loopers.domain.like.QProductLikeSummary; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; @@ -23,6 +25,7 @@ import java.util.Optional; import static com.loopers.domain.brand.QBrand.brand; +import static com.loopers.domain.like.QProductLikeSummary.productLikeSummary; import static com.loopers.domain.product.QProduct.product; import static java.util.stream.Collectors.groupingBy; @@ -144,6 +147,52 @@ public Page search(ProductSearchCondition condition, Pageable pageable) return new PageImpl<>(productsWithOptions, pageable, total != null ? total : 0L); } + @Override + public Page searchWithMaterializedView(ProductSearchCondition condition, Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder(); + builder.and(product.deletedAt.isNull()); + builder.and(product.status.eq(ProductStatus.ON_SALE)); + builder.and(product.displayYn.eq("Y")); + + if (condition.keyword() != null && !condition.keyword().isBlank()) { + builder.and(product.name.containsIgnoreCase(condition.keyword())); + } + if (condition.brandId() != null) { + builder.and(product.brand.id.eq(condition.brandId())); + } + + // MV 기반: summary 테이블의 like_count를 사용하여 정렬 + NumberExpression mvLikeCount = productLikeSummary.likeCount.coalesce(0); + + OrderSpecifier orderSpecifier = switch (condition.sort()) { + case PRICE_ASC -> product.price.asc(); + case LIKE_DESC -> mvLikeCount.desc(); + default -> product.createdAt.desc(); + }; + + JPAQuery query = queryFactory + .selectFrom(product) + .join(product.brand, brand).fetchJoin() + .leftJoin(productLikeSummary) + .on(productLikeSummary.productId.eq(product.id)) + .where(builder) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + List products = query.fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .where(builder) + .fetchOne(); + + List productsWithOptions = assembleWithOptions(products); + + return new PageImpl<>(productsWithOptions, pageable, total != null ? total : 0L); + } + @Override public Page adminSearch(AdminProductSearchCondition condition, Pageable pageable) { BooleanBuilder builder = new BooleanBuilder(); 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 12e9f7ab3..8e22dd9ec 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 @@ -14,8 +14,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -27,38 +27,97 @@ public class ProductV1Controller { private final ProductFacade productFacade; + /** + * 상품 목록 조회 - Redis 적용 + * @param keyword + * @param sort + * @param brandId + * @param pageable + * @return + */ @GetMapping public ApiResponse> getProducts( @RequestParam(required = false) String keyword, @RequestParam(required = false) ProductSortType sort, @RequestParam(required = false) Long brandId, Pageable pageable) { - ProductSearchCondition condition = ProductSearchCondition.of(keyword, sort, brandId); //ProductSearchCondition : 검색 조건을 위한 객체 + ProductSearchCondition condition = ProductSearchCondition.of(keyword, sort, brandId); Page products = productFacade.getProducts(condition, pageable); return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); } + /** + * [TEST] 상품 목록 조회 - 로컬 캐시 적용 + */ + @GetMapping("/local-cache") + public ApiResponse> getProductsWithLocalCache( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) ProductSortType sort, + @RequestParam(required = false) Long brandId, + Pageable pageable) { + ProductSearchCondition condition = ProductSearchCondition.of(keyword, sort, brandId); + Page products = productFacade.getProductsWithLocalCache(condition, pageable); + return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + } + + /** + * [TEST] 상품 목록 조회 - 캐시 미적용 + */ + @GetMapping("/no-cache") + public ApiResponse> getProductsNoCache( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) ProductSortType sort, + @RequestParam(required = false) Long brandId, + Pageable pageable) { + ProductSearchCondition condition = ProductSearchCondition.of(keyword, sort, brandId); + Page products = productFacade.getProductsNoCache(condition, pageable); + return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + } + + /** + * 상품 상세 조회 - Redis 적용 + * @param productId + * @return + */ @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { ProductDetailInfo info = productFacade.getProduct(productId); return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); } + /** + * [TEST] 상품 상세 조회 - 로컬 캐시 적용 + */ + @GetMapping("/{productId}/local-cache") + public ApiResponse getProductWithLocalCache(@PathVariable Long productId) { + ProductDetailInfo info = productFacade.getProductWithLocalCache(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } + + /** + * [TEST] 상품 상세 조회 - 캐시 미적용 + */ + @GetMapping("/{productId}/no-cache") + public ApiResponse getProductNoCache(@PathVariable Long productId) { + ProductDetailInfo info = productFacade.getProductNoCache(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } + @PostMapping("/{productId}/likes") public ApiResponse like(@LoginMember Member member, @PathVariable Long productId) { productFacade.like(member.getId(), productId); return ApiResponse.success(null); } - @PutMapping("/{productId}/likes") + @DeleteMapping("/{productId}/likes") public ApiResponse unlike(@LoginMember Member member, @PathVariable Long productId) { productFacade.unlike(member.getId(), productId); return ApiResponse.success(null); } - @GetMapping("/users/{userId}/likes") + @GetMapping("/me/likes") public ApiResponse> getLikedProducts( - @LoginMember Member member, @PathVariable Long userId, Pageable pageable) { + @LoginMember Member member, Pageable pageable) { Page products = productFacade.getLikedProducts(member.getId(), pageable); return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/AdminProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/AdminProductFacadeTest.java index 9b14eabff..eace7bad1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/AdminProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/AdminProductFacadeTest.java @@ -6,6 +6,7 @@ import com.loopers.domain.cart.CartService; import com.loopers.domain.product.MarginType; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; import com.loopers.domain.product.ProductOption; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductStatus; @@ -42,6 +43,9 @@ class AdminProductFacadeTest { @Mock private CartService cartService; + @Mock + private ProductCacheStore productCacheStore; + @InjectMocks private AdminProductFacade adminProductFacade; @@ -89,13 +93,38 @@ void createProduct_callsBrandAndProductService() { } } + @Nested + @DisplayName("상품 수정을 할 때,") + class UpdateProduct { + + @Test + @DisplayName("상품을 수정하고 상세 캐시를 명시적으로 삭제한다.") + void updatesProduct_andEvictsDetailCache() { + // given + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + when(productService.update(eq(1L), anyString(), anyInt(), anyInt(), anyInt(), + anyInt(), anyString(), any(ProductStatus.class), anyString(), anyList())) + .thenReturn(product); + + // when + adminProductFacade.updateProduct(1L, "에어맥스2", 110000, 85000, 12000, 3000, + "수정된 설명", ProductStatus.ON_SALE, "Y", List.of()); + + // then + verify(productService).update(eq(1L), anyString(), anyInt(), anyInt(), anyInt(), + anyInt(), anyString(), any(ProductStatus.class), anyString(), anyList()); + verify(productCacheStore).evictDetail(1L); + } + } + @Nested @DisplayName("상품 삭제를 할 때,") class DeleteProduct { @Test - @DisplayName("CartService로 장바구니 삭제 후 ProductService로 상품을 삭제한다.") - void deletesCartThenProduct() { + @DisplayName("장바구니 삭제 → 상품 삭제 → 상세 캐시 삭제 순으로 처리한다.") + void deletesCartThenProduct_andEvictsDetailCache() { // given when(productService.findOptionIdsByProductId(1L)).thenReturn(List.of(10L, 20L)); @@ -105,6 +134,7 @@ void deletesCartThenProduct() { // then verify(cartService).deleteByProductOptionIds(List.of(10L, 20L)); verify(productService).softDelete(1L); + verify(productCacheStore).evictDetail(1L); } } } 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 a17d60a14..d77fe9f8e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.like.LikeService; import com.loopers.domain.product.MarginType; import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCacheStore; import com.loopers.domain.product.ProductOption; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductService; @@ -13,7 +14,6 @@ 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 org.springframework.data.domain.Page; @@ -23,8 +23,10 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -38,8 +40,15 @@ class ProductFacadeTest { @Mock private LikeService likeService; - @InjectMocks - private ProductFacade productFacade; + @Mock + private ProductCacheStore redisCacheStore; + + @Mock + private ProductCacheStore localCacheStore; + + private ProductFacade createFacade() { + return new ProductFacade(productService, likeService, redisCacheStore, localCacheStore); + } private Brand createBrandWithId(Long id) { Brand brand = new Brand("나이키", "스포츠"); @@ -63,26 +72,125 @@ private Product createProductWithId(Long id, Brand brand) { } @Nested - @DisplayName("상품 목록 조회를 할 때,") + @DisplayName("상품 목록 조회(Redis)를 할 때,") class GetProducts { @Test - @DisplayName("ProductService에서 조회한 결과를 ProductInfo로 변환한다.") - void returnsProductInfoPage() { + @DisplayName("캐시 히트 시 DB를 조회하지 않고 캐시된 결과를 반환한다.") + void returnsCachedResult_whenCacheHit() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + ProductSearchCondition condition = ProductSearchCondition.of(null, null, null); + Pageable pageable = PageRequest.of(0, 10); + Page cachedPage = new PageImpl<>(List.of(product)); + + when(redisCacheStore.getSearch(condition, pageable)).thenReturn(Optional.of(cachedPage)); + + // when + Page result = facade.getProducts(condition, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스"); + verify(productService, never()).search(condition, pageable); + } + + @Test + @DisplayName("캐시 미스 시 DB에서 조회하고 캐시에 저장한다.") + void queriesDbAndCaches_whenCacheMiss() { // given + ProductFacade facade = createFacade(); Brand brand = createBrandWithId(1L); Product product = createProductWithId(1L, brand); ProductSearchCondition condition = ProductSearchCondition.of(null, null, null); Pageable pageable = PageRequest.of(0, 10); - when(productService.search(condition, pageable)).thenReturn(new PageImpl<>(List.of(product))); + Page dbPage = new PageImpl<>(List.of(product)); + + when(redisCacheStore.getSearch(condition, pageable)).thenReturn(Optional.empty()); + when(productService.search(condition, pageable)).thenReturn(dbPage); // when - Page result = productFacade.getProducts(condition, pageable); + Page result = facade.getProducts(condition, pageable); // then assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스"); + verify(productService).search(condition, pageable); + verify(redisCacheStore).setSearch(condition, pageable, dbPage); + } + } + + @Nested + @DisplayName("상품 상세 조회(Redis)를 할 때,") + class GetProduct { + + @Test + @DisplayName("캐시 히트 시 DB를 조회하지 않고 캐시된 결과를 반환한다.") + void returnsCachedResult_whenCacheHit() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + when(redisCacheStore.getDetail(1L)).thenReturn(Optional.of(product)); + + // when + ProductDetailInfo result = facade.getProduct(1L); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + verify(productService, never()).findById(1L); + } + + @Test + @DisplayName("캐시 미스 시 DB에서 조회하고 캐시에 저장한다.") + void queriesDbAndCaches_whenCacheMiss() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + when(redisCacheStore.getDetail(1L)).thenReturn(Optional.empty()); + when(productService.findById(1L)).thenReturn(product); + + // when + ProductDetailInfo result = facade.getProduct(1L); + + // then + assertThat(result.name()).isEqualTo("에어맥스"); + verify(productService).findById(1L); + verify(redisCacheStore).setDetail(1L, product); + } + } + + @Nested + @DisplayName("상품 목록 조회(로컬 캐시)를 할 때,") + class GetProductsWithLocalCache { + + @Test + @DisplayName("로컬 캐시 히트 시 DB를 조회하지 않고 캐시된 결과를 반환한다.") + void returnsCachedResult_whenLocalCacheHit() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + ProductSearchCondition condition = ProductSearchCondition.of(null, null, null); + Pageable pageable = PageRequest.of(0, 10); + Page cachedPage = new PageImpl<>(List.of(product)); + + when(localCacheStore.getSearch(condition, pageable)).thenReturn(Optional.of(cachedPage)); + + // when + Page result = facade.getProductsWithLocalCache(condition, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + verify(productService, never()).search(condition, pageable); } } @@ -91,13 +199,41 @@ void returnsProductInfoPage() { class LikeProduct { @Test - @DisplayName("LikeService에 위임한다.") - void delegatesToLikeService() { + @DisplayName("상품 존재 확인 후 LikeService에 위임하고, 상태가 변경되면 likeCount를 증가시킨다.") + void delegatesToLikeService_andIncrementsCount_whenChanged() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + when(productService.findById(1L)).thenReturn(product); + when(likeService.like(1L, 1L)).thenReturn(true); + // when - productFacade.like(1L, 1L); + facade.like(1L, 1L); // then + verify(productService).findById(1L); verify(likeService).like(1L, 1L); + verify(productService).incrementLikeCount(1L); + } + + @Test + @DisplayName("이미 좋아요 상태면 likeCount를 증가시키지 않는다. (멱등성)") + void doesNotIncrementCount_whenAlreadyLiked() { + // given + ProductFacade facade = createFacade(); + Brand brand = createBrandWithId(1L); + Product product = createProductWithId(1L, brand); + + when(productService.findById(1L)).thenReturn(product); + when(likeService.like(1L, 1L)).thenReturn(false); + + // when + facade.like(1L, 1L); + + // then + verify(productService, never()).incrementLikeCount(1L); } } @@ -106,13 +242,34 @@ void delegatesToLikeService() { class UnlikeProduct { @Test - @DisplayName("LikeService에 위임한다.") - void delegatesToLikeService() { + @DisplayName("LikeService에 위임하고, 상태가 변경되면 likeCount를 감소시킨다.") + void delegatesToLikeService_andDecrementsCount_whenChanged() { + // given + ProductFacade facade = createFacade(); + + when(likeService.unlike(1L, 1L)).thenReturn(true); + // when - productFacade.unlike(1L, 1L); + facade.unlike(1L, 1L); // then verify(likeService).unlike(1L, 1L); + verify(productService).decrementLikeCount(1L); + } + + @Test + @DisplayName("이미 취소 상태면 likeCount를 감소시키지 않는다. (멱등성)") + void doesNotDecrementCount_whenAlreadyUnliked() { + // given + ProductFacade facade = createFacade(); + + when(likeService.unlike(1L, 1L)).thenReturn(false); + + // when + facade.unlike(1L, 1L); + + // then + verify(productService, never()).decrementLikeCount(1L); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java index 4be927040..677b2bf02 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeConcurrencyTest.java @@ -184,6 +184,111 @@ void allUnlikesSucceed_whenConcurrentUnlike() throws InterruptedException { } } + @DisplayName("같은 회원이 동시에 같은 상품에 좋아요를 중복 요청할 때,") + @Nested + class ConcurrentLikeBySameMember { + + @Test + @DisplayName("하나만 성공하고, likeCount가 1이 된다. (멱등성)") + void onlyOneLikeSucceeds_whenConcurrentLikeBySameMember() throws InterruptedException { + // given - 같은 회원이 동시에 좋아요 요청 + // *테스트 시나리오: 같은 회원이 동시에 5번 좋아요하면? -> likeCount=1 (멱등성 보장) + int threadCount = 5; + Product product = createProduct(); + Long memberId = 1L; + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when - 같은 회원이 동시에 좋아요 요청 + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + productFacade.like(memberId, product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executorService.shutdown(); + + // then - 성공/실패 합계가 threadCount이고, likeCount는 정확히 1 + Product updatedProduct = productService.findById(product.getId()); + + assertAll( + () -> assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount), + () -> assertThat(updatedProduct.getLikeCount()).isEqualTo(1) + ); + } + } + + @DisplayName("같은 회원이 동시에 같은 상품에 좋아요 취소를 중복 요청할 때,") + @Nested + class ConcurrentUnlikeBySameMember { + + @Test + @DisplayName("하나만 성공하고, likeCount가 0이 된다. (멱등성)") + void onlyOneUnlikeSucceeds_whenConcurrentUnlikeBySameMember() throws InterruptedException { + // given - 같은 회원이 좋아요한 상태에서 동시에 취소 요청 + // *테스트 시나리오: 같은 회원이 동시에 5번 좋아요 취소하면? -> likeCount=0 (멱등성 보장) + int threadCount = 5; + Product product = createProduct(); + Long memberId = 1L; + productFacade.like(memberId, product.getId()); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // when - 같은 회원이 동시에 좋아요 취소 요청 + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + productFacade.unlike(memberId, product.getId()); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + doneLatch.await(); + executorService.shutdown(); + + // then - 성공/실패 합계가 threadCount이고, likeCount는 정확히 0 + Product updatedProduct = productService.findById(product.getId()); + + assertAll( + () -> assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount), + () -> assertThat(updatedProduct.getLikeCount()).isEqualTo(0) + ); + } + } + @DisplayName("서로 다른 회원이 동시에 좋아요와 좋아요 취소를 혼합 요청할 때,") @Nested class ConcurrentLikeAndUnlikeMixed { @@ -210,6 +315,7 @@ void likeCountIsCorrect_whenConcurrentLikeAndUnlike() throws InterruptedExceptio CountDownLatch doneLatch = new CountDownLatch(threadCount); // 모든 스레드가 끝났는지 확인 (값: 10) AtomicInteger successCount = new AtomicInteger(0); // 멀티스레드 환경에서 안전한 성공 카운터 + AtomicInteger failCount = new AtomicInteger(0); // 멀티스레드 환경에서 안전한 실패 카운터 // when - 회원 1~5: unlike (좋아요 취소) for (int i = 1; i <= unlikeCount; i++) { @@ -222,7 +328,7 @@ void likeCountIsCorrect_whenConcurrentLikeAndUnlike() throws InterruptedExceptio productFacade.unlike(memberId, product.getId()); // 좋아요 취소 (Atomic UPDATE) successCount.incrementAndGet(); // 성공 카운트 +1 } catch (Exception e) { - // unexpected + failCount.incrementAndGet(); // 실패 카운트 +1 } finally { doneLatch.countDown(); // "나 끝났어!" (성공이든 실패든 반드시 실행) } @@ -240,7 +346,7 @@ void likeCountIsCorrect_whenConcurrentLikeAndUnlike() throws InterruptedExceptio productFacade.like(memberId, product.getId()); // 좋아요 (Atomic UPDATE) successCount.incrementAndGet(); // 성공 카운트 +1 } catch (Exception e) { - // unexpected + failCount.incrementAndGet(); // 실패 카운트 +1 } finally { doneLatch.countDown(); // "나 끝났어!" (성공이든 실패든 반드시 실행) } @@ -258,6 +364,7 @@ void likeCountIsCorrect_whenConcurrentLikeAndUnlike() throws InterruptedExceptio int expectedLikeCount = likeCount; // 5 - 5 + 5 = 5 assertAll( + () -> assertThat(failCount.get()).isEqualTo(0), () -> assertThat(successCount.get()).isEqualTo(threadCount), () -> assertThat(updatedProduct.getLikeCount()).isEqualTo(expectedLikeCount) ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountQueryPerformanceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountQueryPerformanceTest.java new file mode 100644 index 000000000..27aacb196 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountQueryPerformanceTest.java @@ -0,0 +1,255 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.datasource.init.ScriptUtils; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.sql.Connection; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * [성능 비교 테스트 - 비정규화 vs Materialized View] + * + * 테스트 대상: 좋아요순 정렬 조회 성능 (10만건 상품 데이터) + * 테스트 유형: 성능 테스트 + * 테스트 범위: ProductService.search() vs ProductService.searchWithMaterializedView() + * + * 데이터: seed-data.sql (브랜드 80개, 상품 100,000건) + * + likes 10만건 + product_like_summary 갱신 + */ +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class LikeCountQueryPerformanceTest { + + @Autowired + private ProductService productService; + + @Autowired + private LikeService likeService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private DataSource dataSource; + + @Autowired + private EntityManager entityManager; + + @BeforeAll + void setUp() throws Exception { + try (Connection conn = dataSource.getConnection()) { + // 상품 10만건 시드 데이터 로드 + ScriptUtils.executeSqlScript(conn, new ClassPathResource("seed-data.sql")); + + // likes 10만건 생성 + product_like_summary 갱신 + ScriptUtils.executeSqlScript(conn, new ClassPathResource("seed-likes-data.sql")); + } + } + + @AfterAll + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @Order(1) + @DisplayName("[비정규화] 전체 목록 + 좋아요순 정렬 조회 성능 측정") + void denormalized_allProducts_sortByLikeDesc() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, null); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.search(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.search(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSize(20); + assertThat(result.getContent()) + .extracting(Product::getLikeCount) + .isSortedAccordingTo((a, b) -> Integer.compare(b, a)); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[비정규화] 전체 + 좋아요순: 평균 %.2fms (%d회)%n", avgMs, iterations); + } + + @Test + @Order(2) + @DisplayName("[MV] 전체 목록 + 좋아요순 정렬 조회 성능 측정") + void materializedView_allProducts_sortByLikeDesc() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, null); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.searchWithMaterializedView(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.searchWithMaterializedView(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSize(20); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[MV] 전체 + 좋아요순: 평균 %.2fms (%d회)%n", avgMs, iterations); + } + + @Test + @Order(3) + @DisplayName("[비정규화] 브랜드 필터 + 좋아요순 정렬 조회 성능 측정") + void denormalized_brandFilter_sortByLikeDesc() { + // given + Long brandId = 1L; + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brandId); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.search(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.search(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSizeLessThanOrEqualTo(20); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[비정규화] 브랜드 필터 + 좋아요순: 평균 %.2fms (%d회)%n", avgMs, iterations); + } + + @Test + @Order(4) + @DisplayName("[MV] 브랜드 필터 + 좋아요순 정렬 조회 성능 측정") + void materializedView_brandFilter_sortByLikeDesc() { + // given + Long brandId = 1L; + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brandId); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.searchWithMaterializedView(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.searchWithMaterializedView(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSizeLessThanOrEqualTo(20); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[MV] 브랜드 필터 + 좋아요순: 평균 %.2fms (%d회)%n", avgMs, iterations); + } + + @Test + @Order(5) + @DisplayName("[비정규화] 최신순 정렬 조회 성능 측정 (좋아요 JOIN 없는 기준선)") + void denormalized_allProducts_sortByLatest() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LATEST, null); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.search(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.search(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSize(20); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[비정규화] 전체 + 최신순 (기준선): 평균 %.2fms (%d회)%n", avgMs, iterations); + } + + @Test + @Order(6) + @DisplayName("[MV] 최신순 정렬 조회 성능 측정 (LEFT JOIN 오버헤드 확인)") + void materializedView_allProducts_sortByLatest() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LATEST, null); + Pageable pageable = PageRequest.of(0, 20); + + // warm-up + productService.searchWithMaterializedView(condition, pageable); + + // when + int iterations = 10; + long totalElapsed = 0; + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + Page result = productService.searchWithMaterializedView(condition, pageable); + long elapsed = System.nanoTime() - start; + totalElapsed += elapsed; + + assertThat(result.getContent()).hasSize(20); + } + + // then + double avgMs = (totalElapsed / (double) iterations) / 1_000_000.0; + System.out.printf("[MV] 전체 + 최신순 (LEFT JOIN 오버헤드): 평균 %.2fms (%d회)%n", avgMs, iterations); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index c16cbaeee..c7863c071 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -10,7 +10,6 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; -import jakarta.persistence.EntityManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -22,7 +21,6 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -31,6 +29,9 @@ * 테스트 대상: LikeService (Domain Layer) * 테스트 유형: 통합 테스트 (Integration Test) * 테스트 범위: Service → Repository → Database + * + * 주의: LikeService는 Like 엔티티의 상태 전환만 담당한다. + * likeCount 증감은 Facade에서 처리하므로 이 테스트에서는 검증하지 않는다. */ @SpringBootTest @Transactional @@ -45,9 +46,6 @@ class LikeServiceIntegrationTest { @Autowired private BrandService brandService; - @Autowired - private EntityManager entityManager; - @Autowired private DatabaseCleanUp databaseCleanUp; @@ -69,112 +67,114 @@ private Product createProduct() { class LikeAction { @Test - @DisplayName("좋아요를 추가하면, likeCount가 증가한다.") - void incrementsLikeCount_whenLike() { + @DisplayName("신규 좋아요를 추가하면, true를 반환한다.") + void returnsTrue_whenNewLike() { // given Product product = createProduct(); Long memberId = 1L; // when - Like like = likeService.like(memberId, product.getId()); + boolean changed = likeService.like(memberId, product.getId()); // then - assertAll( - () -> assertThat(like.getId()).isNotNull(), - () -> assertThat(like.getMemberId()).isEqualTo(memberId), - () -> assertThat(like.getProductId()).isEqualTo(product.getId()), - () -> assertThat(like.isLiked()).isTrue() - ); - - entityManager.flush(); - entityManager.clear(); - - Product updatedProduct = productService.findById(product.getId()); - assertThat(updatedProduct.getLikeCount()).isEqualTo(1); + assertThat(changed).isTrue(); } @Test - @DisplayName("이미 좋아요한 상태에서 다시 좋아요하면, 멱등하게 동작한다.") - void idempotent_whenAlreadyLiked() { + @DisplayName("이미 좋아요한 상태에서 다시 좋아요하면, false를 반환한다. (멱등성)") + void returnsFalse_whenAlreadyLiked() { // given Product product = createProduct(); Long memberId = 1L; likeService.like(memberId, product.getId()); // when - Like like = likeService.like(memberId, product.getId()); + boolean changed = likeService.like(memberId, product.getId()); // then - assertThat(like.isLiked()).isTrue(); - - entityManager.flush(); - entityManager.clear(); - - Product updatedProduct = productService.findById(product.getId()); - assertThat(updatedProduct.getLikeCount()).isEqualTo(1); + assertThat(changed).isFalse(); } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class UnlikeAction { @Test - @DisplayName("존재하지 않는 상품에 좋아요하면, NOT_FOUND 예외가 발생한다.") - void throwsException_whenProductNotFound() { + @DisplayName("좋아요를 취소하면, true를 반환한다.") + void returnsTrue_whenUnlike() { // given + Product product = createProduct(); Long memberId = 1L; - Long nonExistentProductId = 999L; + likeService.like(memberId, product.getId()); // when - CoreException exception = assertThrows(CoreException.class, - () -> likeService.like(memberId, nonExistentProductId)); + boolean changed = likeService.unlike(memberId, product.getId()); // then - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(changed).isTrue(); } - } - - @DisplayName("좋아요를 취소할 때,") - @Nested - class UnlikeAction { @Test - @DisplayName("좋아요를 취소하면, likeCount가 감소한다.") - void decrementsLikeCount_whenUnlike() { + @DisplayName("이미 취소된 상태에서 다시 취소하면, false를 반환한다. (멱등성)") + void returnsFalse_whenAlreadyUnliked() { // given Product product = createProduct(); Long memberId = 1L; likeService.like(memberId, product.getId()); + likeService.unlike(memberId, product.getId()); // when - Like like = likeService.unlike(memberId, product.getId()); + boolean changed = likeService.unlike(memberId, product.getId()); // then - assertThat(like.isLiked()).isFalse(); - - entityManager.flush(); - entityManager.clear(); - - Product updatedProduct = productService.findById(product.getId()); - assertThat(updatedProduct.getLikeCount()).isEqualTo(0); + assertThat(changed).isFalse(); } @Test - @DisplayName("이미 취소된 상태에서 다시 취소하면, 멱등하게 동작한다.") - void idempotent_whenAlreadyUnliked() { + @DisplayName("좋아요 기록이 없는 상태에서 취소하면, BAD_REQUEST 예외가 발생한다.") + void throwsException_whenNoLikeRecord() { // given Product product = createProduct(); Long memberId = 1L; - likeService.like(memberId, product.getId()); - likeService.unlike(memberId, product.getId()); // when - Like like = likeService.unlike(memberId, product.getId()); + CoreException exception = assertThrows(CoreException.class, + () -> likeService.unlike(memberId, product.getId())); // then - assertThat(like.isLiked()).isFalse(); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } - entityManager.flush(); - entityManager.clear(); + @DisplayName("좋아요 순차 전환 시,") + @Nested + class LikeStateTransition { + + @Test + @DisplayName("좋아요 → 취소 → 다시 좋아요하면, 모두 true를 반환한다.") + void allTransitionsReturnTrue() { + // given + Product product = createProduct(); + Long memberId = 1L; + + // when & then + assertThat(likeService.like(memberId, product.getId())).isTrue(); + assertThat(likeService.unlike(memberId, product.getId())).isTrue(); + assertThat(likeService.like(memberId, product.getId())).isTrue(); + } + + @Test + @DisplayName("여러 회원이 좋아요하면, 모두 true를 반환한다.") + void allLikesReturnTrue_whenMultipleMembers() { + // given + Product product = createProduct(); + int memberCount = 5; - Product updatedProduct = productService.findById(product.getId()); - assertThat(updatedProduct.getLikeCount()).isEqualTo(0); + // when & then + for (long memberId = 1; memberId <= memberCount; memberId++) { + assertThat(likeService.like(memberId, product.getId())).isTrue(); + } } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 137a7eaf9..273a8f17b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -1,7 +1,6 @@ package com.loopers.domain.like; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -35,7 +34,10 @@ * * 테스트 대상: LikeService * 테스트 유형: 단위 테스트 (Mock 사용) - * 테스트 더블: Mock (LikeRepository, ProductRepository) + * 테스트 더블: Mock (LikeRepository) + * + * LikeService는 Like 엔티티의 상태 전환만 담당한다. + * 상품 존재 여부 확인, likeCount 증감은 Facade에서 처리하므로 이 테스트에서는 검증하지 않는다. */ @ExtendWith(MockitoExtension.class) @DisplayName("LikeService 단위 테스트") @@ -44,9 +46,6 @@ class LikeServiceTest { @Mock private LikeRepository likeRepository; - @Mock - private ProductRepository productRepository; - @InjectMocks private LikeService likeService; @@ -74,82 +73,53 @@ private Like createLikeWithId(Long id, Long memberId, Long productId, String lik class LikeAction { @Test - @DisplayName("좋아요 기록이 없으면 새로 생성한다.") + @DisplayName("좋아요 기록이 없으면 새로 생성하고 true를 반환한다.") void createsNewLike_whenNoExistingRecord() { // given - Product product = createProduct(); - when(productRepository.findById(PRODUCT_ID)).thenReturn(Optional.of(product)); when(likeRepository.findByMemberIdAndProductId(MEMBER_ID, PRODUCT_ID)).thenReturn(Optional.empty()); when(likeRepository.save(any(Like.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when - Like result = likeService.like(MEMBER_ID, PRODUCT_ID); + boolean result = likeService.like(MEMBER_ID, PRODUCT_ID); // then assertAll( - () -> assertThat(result.getMemberId()).isEqualTo(MEMBER_ID), - () -> assertThat(result.getProductId()).isEqualTo(PRODUCT_ID), - () -> assertThat(result.isLiked()).isTrue(), - () -> verify(likeRepository, times(1)).save(any(Like.class)), - () -> verify(productRepository).incrementLikeCount(PRODUCT_ID)); + () -> assertThat(result).isTrue(), + () -> verify(likeRepository, times(1)).save(any(Like.class))); } @Test - @DisplayName("좋아요 취소 상태(N)에서 좋아요하면 Y로 전환된다.") + @DisplayName("좋아요 취소 상태(N)에서 좋아요하면 Y로 전환하고 true를 반환한다.") void changesLikeYnToY_whenPreviouslyUnliked() { // given - Product product = createProduct(); Like existingLike = createLikeWithId(1L, MEMBER_ID, PRODUCT_ID, "N"); - when(productRepository.findById(PRODUCT_ID)).thenReturn(Optional.of(product)); when(likeRepository.findByMemberIdAndProductId(MEMBER_ID, PRODUCT_ID)).thenReturn(Optional.of(existingLike)); when(likeRepository.save(any(Like.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when - Like result = likeService.like(MEMBER_ID, PRODUCT_ID); + boolean result = likeService.like(MEMBER_ID, PRODUCT_ID); // then assertAll( - () -> assertThat(result.isLiked()).isTrue(), - () -> verify(likeRepository, times(1)).save(any(Like.class)), - () -> verify(productRepository).incrementLikeCount(PRODUCT_ID)); + () -> assertThat(result).isTrue(), + () -> verify(likeRepository, times(1)).save(any(Like.class))); } @Test - @DisplayName("이미 좋아요 상태(Y)이면 아무것도 하지 않는다. (멱등성)") + @DisplayName("이미 좋아요 상태(Y)이면 아무것도 하지 않고 false를 반환한다. (멱등성)") void doesNothing_whenAlreadyLiked() { // given - Product product = createProduct(); Like existingLike = createLikeWithId(1L, MEMBER_ID, PRODUCT_ID, "Y"); - when(productRepository.findById(PRODUCT_ID)).thenReturn(Optional.of(product)); when(likeRepository.findByMemberIdAndProductId(MEMBER_ID, PRODUCT_ID)).thenReturn(Optional.of(existingLike)); // when - Like result = likeService.like(MEMBER_ID, PRODUCT_ID); + boolean result = likeService.like(MEMBER_ID, PRODUCT_ID); // then assertAll( - () -> assertThat(result.isLiked()).isTrue(), - () -> verify(likeRepository, never()).save(any(Like.class)), - () -> verify(productRepository, never()).incrementLikeCount(any())); - } - - @Test - @DisplayName("상품이 존재하지 않으면 NOT_FOUND 예외가 발생한다.") - void throwsNotFound_whenProductNotExists() { - // given - when(productRepository.findById(PRODUCT_ID)).thenReturn(Optional.empty()); - - // when - CoreException exception = assertThrows(CoreException.class, - () -> likeService.like(MEMBER_ID, PRODUCT_ID)); - - // then - assertAll( - () -> assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND), - () -> assertThat(exception.getMessage()).contains("상품"), - () -> verify(likeRepository, never()).findByMemberIdAndProductId(any(), any()), + () -> assertThat(result).isFalse(), () -> verify(likeRepository, never()).save(any(Like.class))); } } @@ -159,7 +129,7 @@ void throwsNotFound_whenProductNotExists() { class UnlikeAction { @Test - @DisplayName("좋아요 상태(Y)에서 취소하면 N으로 전환된다.") + @DisplayName("좋아요 상태(Y)에서 취소하면 N으로 전환하고 true를 반환한다.") void changesLikeYnToN_whenCurrentlyLiked() { // given Like existingLike = createLikeWithId(1L, MEMBER_ID, PRODUCT_ID, "Y"); @@ -168,17 +138,16 @@ void changesLikeYnToN_whenCurrentlyLiked() { when(likeRepository.save(any(Like.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when - Like result = likeService.unlike(MEMBER_ID, PRODUCT_ID); + boolean result = likeService.unlike(MEMBER_ID, PRODUCT_ID); // then assertAll( - () -> assertThat(result.isLiked()).isFalse(), - () -> verify(likeRepository, times(1)).save(any(Like.class)), - () -> verify(productRepository).decrementLikeCount(PRODUCT_ID)); + () -> assertThat(result).isTrue(), + () -> verify(likeRepository, times(1)).save(any(Like.class))); } @Test - @DisplayName("이미 좋아요 취소 상태(N)이면 아무것도 하지 않는다. (멱등성)") + @DisplayName("이미 좋아요 취소 상태(N)이면 아무것도 하지 않고 false를 반환한다. (멱등성)") void doesNothing_whenAlreadyUnliked() { // given Like existingLike = createLikeWithId(1L, MEMBER_ID, PRODUCT_ID, "N"); @@ -186,13 +155,12 @@ void doesNothing_whenAlreadyUnliked() { when(likeRepository.findByMemberIdAndProductId(MEMBER_ID, PRODUCT_ID)).thenReturn(Optional.of(existingLike)); // when - Like result = likeService.unlike(MEMBER_ID, PRODUCT_ID); + boolean result = likeService.unlike(MEMBER_ID, PRODUCT_ID); // then assertAll( - () -> assertThat(result.isLiked()).isFalse(), - () -> verify(likeRepository, never()).save(any(Like.class)), - () -> verify(productRepository, never()).decrementLikeCount(any())); + () -> assertThat(result).isFalse(), + () -> verify(likeRepository, never()).save(any(Like.class))); } @Test @@ -213,6 +181,21 @@ void throwsBadRequest_whenNoLikeRecord() { } } + @Nested + @DisplayName("MV를 갱신할 때,") + class RefreshLikeSummary { + + @Test + @DisplayName("likeRepository.refreshLikeSummary가 호출된다.") + void delegatesToRepository() { + // given & when + likeService.refreshLikeSummary(); + + // then + verify(likeRepository, times(1)).refreshLikeSummary(); + } + } + @Nested @DisplayName("좋아요 상품 목록을 조회할 때,") class GetLikedProducts { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeSummaryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeSummaryIntegrationTest.java new file mode 100644 index 000000000..590e51155 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeSummaryIntegrationTest.java @@ -0,0 +1,232 @@ +package com.loopers.domain.like; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * [통합 테스트 - Materialized View 기반 좋아요 수 조회] + * + * 테스트 대상: ProductLikeSummary (Materialized View) + * 테스트 유형: 통합 테스트 (Integration Test) + * 테스트 범위: LikeService.refreshLikeSummary() → ProductService.searchWithMaterializedView() + */ +@SpringBootTest +@Transactional +class ProductLikeSummaryIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private ProductService productService; + + @Autowired + private ProductFacade productFacade; + + @Autowired + private BrandService brandService; + + @Autowired + private EntityManager entityManager; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Brand createBrand(String name) { + Brand brand = brandService.register(name, name + " 설명"); + return brandService.update(brand.getId(), name, name + " 설명", BrandStatus.ACTIVE); + } + + private Product createProduct(Brand brand, String name, int price) { + ProductOption option = new ProductOption(null, "기본 옵션", 100); + return productService.register(brand, name, price, MarginType.RATE, 10, + 0, 3000, name + " 설명", List.of(option)); + } + + @DisplayName("MV 갱신(refreshLikeSummary) 시,") + @Nested + class RefreshLikeSummary { + + @Test + @DisplayName("Like 테이블의 집계 결과가 product_like_summary에 정확히 반영된다.") + void refreshesSummaryFromLikesTable() { + // given + Brand brand = createBrand("나이키"); + Product product1 = createProduct(brand, "에어맥스", 100000); + Product product2 = createProduct(brand, "에어포스", 120000); + + // product1: 3명 좋아요 + likeService.like(1L, product1.getId()); + likeService.like(2L, product1.getId()); + likeService.like(3L, product1.getId()); + + // product2: 1명 좋아요 + likeService.like(1L, product2.getId()); + + entityManager.flush(); + entityManager.clear(); + + // when + likeService.refreshLikeSummary(); + entityManager.flush(); + entityManager.clear(); + + // then - MV 기반 검색으로 좋아요순 정렬 검증 + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brand.getId()); + Pageable pageable = PageRequest.of(0, 20); + + Page result = productService.searchWithMaterializedView(condition, pageable); + + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent().get(0).getId()).isEqualTo(product1.getId()), + () -> assertThat(result.getContent().get(1).getId()).isEqualTo(product2.getId()) + ); + } + + @Test + @DisplayName("좋아요 취소 후 갱신하면, 취소된 좋아요가 반영된다.") + void reflectsUnlikesAfterRefresh() { + // given + Brand brand = createBrand("아디다스"); + Product product1 = createProduct(brand, "울트라부스트", 200000); + Product product2 = createProduct(brand, "스탠스미스", 150000); + + // product1: 2명 좋아요 + likeService.like(1L, product1.getId()); + likeService.like(2L, product1.getId()); + + // product2: 3명 좋아요 → 1명 취소 = 실질 2명 + likeService.like(1L, product2.getId()); + likeService.like(2L, product2.getId()); + likeService.like(3L, product2.getId()); + likeService.unlike(3L, product2.getId()); + + entityManager.flush(); + entityManager.clear(); + + // when + likeService.refreshLikeSummary(); + entityManager.flush(); + entityManager.clear(); + + // then - 둘 다 2개이므로 결과에 둘 다 포함 + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brand.getId()); + Pageable pageable = PageRequest.of(0, 20); + + Page result = productService.searchWithMaterializedView(condition, pageable); + + assertThat(result.getContent()).hasSize(2); + } + + @Test + @DisplayName("좋아요가 없는 상품도 MV 기반 검색에서 조회된다.") + void includesProductsWithNoLikes() { + // given + Brand brand = createBrand("뉴발란스"); + Product productWithLikes = createProduct(brand, "993", 250000); + Product productWithoutLikes = createProduct(brand, "574", 100000); + + likeService.like(1L, productWithLikes.getId()); + + entityManager.flush(); + entityManager.clear(); + + likeService.refreshLikeSummary(); + entityManager.flush(); + entityManager.clear(); + + // when + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brand.getId()); + Pageable pageable = PageRequest.of(0, 20); + + Page result = productService.searchWithMaterializedView(condition, pageable); + + // then - LEFT JOIN이므로 좋아요 없는 상품도 포함 + assertAll( + () -> assertThat(result.getContent()).hasSize(2), + () -> assertThat(result.getContent().get(0).getId()).isEqualTo(productWithLikes.getId()) + ); + } + } + + @DisplayName("비정규화 vs MV 결과 비교 시,") + @Nested + class DenormalizedVsMaterializedView { + + @Test + @DisplayName("MV 갱신 직후, 비정규화와 MV 기반 검색의 정렬 결과가 동일하다.") + void sameResultWhenMvIsFresh() { + // given + Brand brand = createBrand("푸마"); + Product product1 = createProduct(brand, "RS-X", 130000); + Product product2 = createProduct(brand, "Suede", 90000); + Product product3 = createProduct(brand, "Clyde", 110000); + + // product2: 5명, product3: 3명, product1: 1명 + // Facade를 통해 호출하여 Like 생성 + product.likeCount 증감을 함께 수행 + for (long i = 1; i <= 5; i++) { + productFacade.like(i, product2.getId()); + } + for (long i = 1; i <= 3; i++) { + productFacade.like(i, product3.getId()); + } + productFacade.like(1L, product1.getId()); + + entityManager.flush(); + entityManager.clear(); + + likeService.refreshLikeSummary(); + entityManager.flush(); + entityManager.clear(); + + // when + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brand.getId()); + Pageable pageable = PageRequest.of(0, 20); + + Page denormalizedResult = productService.search(condition, pageable); + Page mvResult = productService.searchWithMaterializedView(condition, pageable); + + // then - 정렬 순서가 동일 + assertAll( + () -> assertThat(denormalizedResult.getContent()).hasSize(3), + () -> assertThat(mvResult.getContent()).hasSize(3), + () -> assertThat(denormalizedResult.getContent()) + .extracting(Product::getId) + .containsExactlyElementsOf( + mvResult.getContent().stream().map(Product::getId).toList() + ) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSearchIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSearchIntegrationTest.java new file mode 100644 index 000000000..be94ccd56 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSearchIntegrationTest.java @@ -0,0 +1,207 @@ +package com.loopers.domain.product; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterAll; +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.core.io.ClassPathResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.datasource.init.ScriptUtils; + +import javax.sql.DataSource; +import java.sql.Connection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * [통합 테스트 - 상품 검색 성능] + * + * 테스트 대상: ProductService.search (Domain Layer) + * 테스트 유형: 통합 테스트 (Integration Test) + * 테스트 범위: Service → Repository(QueryDSL) → Database + * 데이터: seed-data.sql (브랜드 80개, 상품 10만건, 옵션 ~20만건) + */ +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProductSearchIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private DataSource dataSource; + + @BeforeAll + void setUp() throws Exception { + try (Connection conn = dataSource.getConnection()) { + ScriptUtils.executeSqlScript(conn, new ClassPathResource("seed-data.sql")); + } + } + + @AfterAll + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드 필터 + 좋아요순 정렬 조회 시,") + @Nested + class BrandFilterWithLikeSort { + + @Test + @DisplayName("특정 브랜드의 상품을 좋아요 내림차순으로 조회한다.") + void searchByBrand_sortByLikeDesc() { + // given + Long brandId = 1L; + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, brandId); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).isNotEmpty(), + () -> assertThat(result.getContent()).hasSizeLessThanOrEqualTo(20), + () -> assertThat(result.getContent()) + .allMatch(p -> p.getBrandId().equals(brandId)), + () -> assertThat(result.getContent()) + .extracting(Product::getLikeCount) + .isSortedAccordingTo((a, b) -> Integer.compare(b, a)) + ); + } + } + + @DisplayName("브랜드 필터 + 최신순 정렬 조회 시,") + @Nested + class BrandFilterWithLatestSort { + + @Test + @DisplayName("특정 브랜드의 상품을 최신순으로 조회한다.") + void searchByBrand_sortByLatest() { + // given + Long brandId = 1L; + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LATEST, brandId); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).isNotEmpty(), + () -> assertThat(result.getContent()).hasSizeLessThanOrEqualTo(20), + () -> assertThat(result.getContent()) + .allMatch(p -> p.getBrandId().equals(brandId)), + () -> assertThat(result.getContent()) + .extracting(Product::getCreatedAt) + .isSortedAccordingTo((a, b) -> b.compareTo(a)) + ); + } + } + + @DisplayName("전체 목록 + 좋아요순 정렬 조회 시,") + @Nested + class AllProductsWithLikeSort { + + @Test + @DisplayName("브랜드 필터 없이 좋아요 내림차순으로 조회한다.") + void searchAll_sortByLikeDesc() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LIKE_DESC, null); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(20), + () -> assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(50000L), + () -> assertThat(result.getContent()) + .extracting(Product::getLikeCount) + .isSortedAccordingTo((a, b) -> Integer.compare(b, a)) + ); + } + } + + @DisplayName("전체 목록 + 최신순 정렬 조회 시,") + @Nested + class AllProductsWithLatestSort { + + @Test + @DisplayName("브랜드 필터 없이 최신순으로 조회한다.") + void searchAll_sortByLatest() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LATEST, null); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(20), + () -> assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(50000L), + () -> assertThat(result.getContent()) + .extracting(Product::getCreatedAt) + .isSortedAccordingTo((a, b) -> b.compareTo(a)) + ); + } + } + + @DisplayName("전체 목록 + 가격순 정렬 조회 시,") + @Nested + class AllProductsWithPriceSort { + + @Test + @DisplayName("브랜드 필터 없이 가격 오름차순으로 조회한다.") + void searchAll_sortByPriceAsc() { + // given + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.PRICE_ASC, null); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertAll( + () -> assertThat(result.getContent()).hasSize(20), + () -> assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(50000L), + () -> assertThat(result.getContent()) + .extracting(Product::getPrice) + .isSorted() + ); + } + } + + @DisplayName("브랜드 필터 + COUNT 조회 시,") + @Nested + class BrandFilterCount { + + @Test + @DisplayName("특정 브랜드의 전체 상품 수를 조회한다.") + void countByBrand() { + // given + Long brandId = 1L; + ProductSearchCondition condition = ProductSearchCondition.of(null, ProductSortType.LATEST, brandId); + Pageable pageable = PageRequest.of(0, 20); + + // when + Page result = productService.search(condition, pageable); + + // then + assertThat(result.getTotalElements()).isGreaterThan(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 64df8f211..0e2bfd1a7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -14,6 +14,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import jakarta.persistence.EntityManager; + import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -40,6 +42,9 @@ class ProductServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private EntityManager entityManager; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); @@ -166,6 +171,65 @@ void softDeletesProduct_whenExists() { } } + @DisplayName("좋아요 수를 증감할 때,") + @Nested + class LikeCount { + + @Test + @DisplayName("incrementLikeCount로 좋아요 수가 1 증가한다.") + void incrementsLikeCount() { + // given + Brand brand = createActiveBrand("나이키"); + Product savedProduct = registerProduct(brand, "에어맥스", 100000, MarginType.RATE, 10, 100); + + // when + productService.incrementLikeCount(savedProduct.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + Product product = productService.findById(savedProduct.getId()); + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("decrementLikeCount로 좋아요 수가 1 감소한다.") + void decrementsLikeCount() { + // given + Brand brand = createActiveBrand("나이키"); + Product savedProduct = registerProduct(brand, "에어맥스", 100000, MarginType.RATE, 10, 100); + productService.incrementLikeCount(savedProduct.getId()); + entityManager.flush(); + entityManager.clear(); + + // when + productService.decrementLikeCount(savedProduct.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + Product product = productService.findById(savedProduct.getId()); + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("likeCount가 0일 때 decrementLikeCount를 호출하면 음수가 되지 않는다.") + void doesNotGoNegative_whenLikeCountIsZero() { + // given + Brand brand = createActiveBrand("나이키"); + Product savedProduct = registerProduct(brand, "에어맥스", 100000, MarginType.RATE, 10, 100); + + // when + productService.decrementLikeCount(savedProduct.getId()); + entityManager.flush(); + entityManager.clear(); + + // then + Product product = productService.findById(savedProduct.getId()); + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + @DisplayName("재고를 차감할 때,") @Nested class DeductStock { 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 350d56167..0c2ed4b2d 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 @@ -396,6 +396,55 @@ void callsSoftDeleteByBrandId() { } } + @Nested + @DisplayName("좋아요 수를 증가시킬 때,") + class IncrementLikeCount { + + @Test + @DisplayName("productRepository.incrementLikeCount가 호출된다.") + void callsRepositoryIncrementLikeCount() { + // given + when(productRepository.incrementLikeCount(1L)).thenReturn(1); + + // when + productService.incrementLikeCount(1L); + + // then + verify(productRepository, times(1)).incrementLikeCount(1L); + } + } + + @Nested + @DisplayName("좋아요 수를 감소시킬 때,") + class DecrementLikeCount { + + @Test + @DisplayName("likeCount가 1 이상이면 감소에 성공한다.") + void decrementsSuccessfully_whenLikeCountAboveZero() { + // given + when(productRepository.decrementLikeCount(1L)).thenReturn(1); + + // when + productService.decrementLikeCount(1L); + + // then + verify(productRepository, times(1)).decrementLikeCount(1L); + } + + @Test + @DisplayName("likeCount가 이미 0이면 감소하지 않고 경고 로그를 남긴다.") + void doesNotDecrement_whenLikeCountIsZero() { + // given + when(productRepository.decrementLikeCount(1L)).thenReturn(0); + + // when + productService.decrementLikeCount(1L); + + // then + verify(productRepository, times(1)).decrementLikeCount(1L); + } + } + @Nested @DisplayName("재고를 차감할 때,") class DeductStock { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CachePerformanceComparisonTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CachePerformanceComparisonTest.java new file mode 100644 index 000000000..9c2f39acc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CachePerformanceComparisonTest.java @@ -0,0 +1,486 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cache.CacheManager; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * [성능 비교 테스트 - Redis Cache vs Local Cache (Caffeine)] + * + * 테스트 대상: 상품 목록/상세 API의 캐시 전략별 응답 시간 비교 + * 테스트 유형: 성능 테스트 (E2E - HTTP 호출) + * + * 측정 항목: + * 1) Cache Miss 응답시간 (첫 요청: DB 조회 + 캐시 저장) + * 2) Cache Hit 응답시간 (캐시에서 반환) + * 3) 반복 호출 평균/p95/p99 응답시간 + * 4) 캐시 무효화 후 재조회 비용 + * + * 데이터: 브랜드 5개, 상품 50개, 옵션 각 1개 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CachePerformanceComparisonTest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + private static final int ITERATIONS = 50; + + @Autowired + private TestRestTemplate testRestTemplate; + @Autowired + private BrandJpaRepository brandJpaRepository; + @Autowired + private ProductJpaRepository productJpaRepository; + @Autowired + private ProductOptionJpaRepository productOptionJpaRepository; + @Autowired + private DatabaseCleanUp databaseCleanUp; + @Autowired + private RedisCleanUp redisCleanUp; + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private CacheManager cacheManager; + + private Long sampleProductId; + + @BeforeAll + void setUp() { + // 브랜드 5개, 상품 50개, 옵션 각 1개 생성 + List brands = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Brand brand = new Brand("브랜드" + i, "설명" + i); + brand.changeStatus(BrandStatus.ACTIVE); + brands.add(brandJpaRepository.save(brand)); + } + + for (int i = 0; i < 50; i++) { + Brand brand = brands.get(i % 5); + Product product = new Product(brand, "테스트상품" + i, 10000 + i * 100, 8000 + i * 80, + 1000, 2500, "상품 설명" + i, MarginType.AMOUNT, ProductStatus.ON_SALE, "Y", List.of()); + Product saved = productJpaRepository.save(product); + productOptionJpaRepository.save(new ProductOption(saved.getId(), "옵션" + i, 100)); + + if (i == 0) { + sampleProductId = saved.getId(); + } + } + } + + @AfterAll + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + Objects.requireNonNull(cacheManager.getCache("productSearch")).clear(); + Objects.requireNonNull(cacheManager.getCache("productDetail")).clear(); + } + + // ==================== 1. 상품 목록 조회 성능 비교 ==================== + + @Test + @Order(1) + @DisplayName("[No Cache] 상품 목록 조회 - DB 직접 조회 기준선 측정") + void baseline_noCache_productList() { + // given - 캐시를 모두 비운 상태에서 매번 DB 직접 조회 (page=3은 캐시 미적용) + String url = ENDPOINT_PRODUCTS + "?sort=LATEST&page=3&size=10"; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // warm-up + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[No Cache] 상품 목록 (DB 직접)", durations); + } + + @Test + @Order(2) + @DisplayName("[Redis] 상품 목록 조회 - Cache Miss(첫 요청) 측정") + void redis_cacheMiss_productList() { + // given - Redis 캐시 비우기 + redisCleanUp.truncateAll(); + String url = ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // when - Cache Miss (DB 조회 + Redis 저장) + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + System.out.printf("[Redis] 상품 목록 Cache Miss: %.2fms%n", elapsed / 1_000_000.0); + } + + @Test + @Order(3) + @DisplayName("[Redis] 상품 목록 조회 - Cache Hit 반복 성능 측정") + void redis_cacheHit_productList() { + // given - Order(2)에서 이미 캐시가 채워진 상태 + String url = ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // warm-up + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Redis] 상품 목록 Cache Hit", durations); + } + + @Test + @Order(4) + @DisplayName("[Local] 상품 목록 조회 - Cache Miss(첫 요청) 측정") + void local_cacheMiss_productList() { + // given - 로컬 캐시 비우기 + Objects.requireNonNull(cacheManager.getCache("productSearch")).clear(); + String url = ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // when - Cache Miss (DB 조회 + 로컬 저장) + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + System.out.printf("[Local] 상품 목록 Cache Miss: %.2fms%n", elapsed / 1_000_000.0); + } + + @Test + @Order(5) + @DisplayName("[Local] 상품 목록 조회 - Cache Hit 반복 성능 측정") + void local_cacheHit_productList() { + // given - Order(4)에서 이미 캐시가 채워진 상태 + String url = ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // warm-up + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Local] 상품 목록 Cache Hit", durations); + } + + // ==================== 2. 상품 상세 조회 성능 비교 ==================== + + @Test + @Order(6) + @DisplayName("[No Cache] 상품 상세 조회 - DB 직접 조회 기준선 측정") + void baseline_noCache_productDetail() { + // given - 매번 캐시를 비워서 DB 직접 조회 강제 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // warm-up + redisCleanUp.truncateAll(); + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + // 매 반복마다 Redis 캐시 삭제하여 항상 Cache Miss 유도 + redisTemplate.delete("product:detail:" + sampleProductId); + + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[No Cache] 상품 상세 (DB 직접)", durations); + } + + @Test + @Order(7) + @DisplayName("[Redis] 상품 상세 조회 - Cache Hit 반복 성능 측정") + void redis_cacheHit_productDetail() { + // given - 캐시 채우기 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + + // warm-up + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Redis] 상품 상세 Cache Hit", durations); + } + + @Test + @Order(8) + @DisplayName("[Local] 상품 상세 조회 - Cache Hit 반복 성능 측정") + void local_cacheHit_productDetail() { + // given - 캐시 채우기 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId + "/local-cache", HttpMethod.GET, null, responseType); + + // warm-up + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId + "/local-cache", HttpMethod.GET, null, responseType); + + // when + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId + "/local-cache", + HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Local] 상품 상세 Cache Hit", durations); + } + + // ==================== 3. 캐시 무효화 후 재조회 비용 ==================== + + @Test + @Order(9) + @DisplayName("[Redis] 캐시 evict 후 재조회 비용 측정 (상세)") + void redis_evictAndReload_productDetail() { + // given + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - evict → 재조회를 반복하여 무효화 비용 측정 + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + // 캐시 삭제 (관리자 상품 수정 시나리오) + redisTemplate.delete("product:detail:" + sampleProductId); + + // 재조회 (Cache Miss → DB 조회 → Redis 저장) + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId, HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Redis] 상품 상세 evict 후 재조회", durations); + } + + @Test + @Order(10) + @DisplayName("[Local] 캐시 evict 후 재조회 비용 측정 (상세)") + void local_evictAndReload_productDetail() { + // given + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - evict → 재조회를 반복하여 무효화 비용 측정 + List durations = new ArrayList<>(); + for (int i = 0; i < ITERATIONS; i++) { + // 캐시 삭제 + Objects.requireNonNull(cacheManager.getCache("productDetail")).clear(); + + // 재조회 (Cache Miss → DB 조회 → Local 저장) + long start = System.nanoTime(); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + sampleProductId + "/local-cache", + HttpMethod.GET, null, responseType); + long elapsed = System.nanoTime() - start; + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + durations.add(elapsed); + } + + // then + printStats("[Local] 상품 상세 evict 후 재조회", durations); + } + + // ==================== 4. 다양한 검색 조건에서의 캐시 성능 ==================== + + @Test + @Order(11) + @DisplayName("[Redis] 다양한 검색 조건 Cache Miss → Hit 전환 비용 측정") + void redis_variousConditions() { + // given - 캐시 초기화 + redisCleanUp.truncateAll(); + + String[] urls = { + ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10", + ENDPOINT_PRODUCTS + "?sort=LIKE_DESC&page=0&size=10", + ENDPOINT_PRODUCTS + "?sort=PRICE_ASC&page=0&size=10", + ENDPOINT_PRODUCTS + "?sort=LATEST&page=1&size=10", + ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=20", + }; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // when - 첫 순회: 모든 조건 Cache Miss + long totalMiss = 0; + for (String url : urls) { + long start = System.nanoTime(); + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + totalMiss += System.nanoTime() - start; + } + + // when - 두 번째 순회: 모든 조건 Cache Hit + long totalHit = 0; + for (String url : urls) { + long start = System.nanoTime(); + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + totalHit += System.nanoTime() - start; + } + + // then + double avgMissMs = (totalMiss / (double) urls.length) / 1_000_000.0; + double avgHitMs = (totalHit / (double) urls.length) / 1_000_000.0; + System.out.printf("[Redis] 다양한 조건 - Cache Miss 평균: %.2fms, Cache Hit 평균: %.2fms (%.1f%% 감소)%n", + avgMissMs, avgHitMs, (1 - avgHitMs / avgMissMs) * 100); + } + + @Test + @Order(12) + @DisplayName("[Local] 다양한 검색 조건 Cache Miss → Hit 전환 비용 측정") + void local_variousConditions() { + // given - 캐시 초기화 + Objects.requireNonNull(cacheManager.getCache("productSearch")).clear(); + + String[] urls = { + ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=10", + ENDPOINT_PRODUCTS + "/local-cache?sort=LIKE_DESC&page=0&size=10", + ENDPOINT_PRODUCTS + "/local-cache?sort=PRICE_ASC&page=0&size=10", + ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=1&size=10", + ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=20", + }; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + // when - 첫 순회: 모든 조건 Cache Miss + long totalMiss = 0; + for (String url : urls) { + long start = System.nanoTime(); + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + totalMiss += System.nanoTime() - start; + } + + // when - 두 번째 순회: 모든 조건 Cache Hit + long totalHit = 0; + for (String url : urls) { + long start = System.nanoTime(); + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + totalHit += System.nanoTime() - start; + } + + // then + double avgMissMs = (totalMiss / (double) urls.length) / 1_000_000.0; + double avgHitMs = (totalHit / (double) urls.length) / 1_000_000.0; + System.out.printf("[Local] 다양한 조건 - Cache Miss 평균: %.2fms, Cache Hit 평균: %.2fms (%.1f%% 감소)%n", + avgMissMs, avgHitMs, (1 - avgHitMs / avgMissMs) * 100); + } + + // ==================== 통계 유틸 ==================== + + private void printStats(String label, List durationsNano) { + List sorted = durationsNano.stream().sorted().toList(); + int size = sorted.size(); + + double avgMs = sorted.stream().mapToLong(Long::longValue).average().orElse(0) / 1_000_000.0; + double minMs = sorted.getFirst() / 1_000_000.0; + double maxMs = sorted.getLast() / 1_000_000.0; + double p50Ms = sorted.get((int) (size * 0.50)) / 1_000_000.0; + double p95Ms = sorted.get((int) (size * 0.95)) / 1_000_000.0; + double p99Ms = sorted.get(Math.min((int) (size * 0.99), size - 1)) / 1_000_000.0; + + System.out.printf(""" + %n========== %s (%d회) ========== + 평균: %.2fms + 최소: %.2fms | 최대: %.2fms + p50: %.2fms | p95: %.2fms | p99: %.2fms + ===========================================%n""", + label, size, avgMs, minMs, maxMs, p50Ms, p95Ms, p99Ms); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheEvictionE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheEvictionE2ETest.java new file mode 100644 index 000000000..4226a6435 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheEvictionE2ETest.java @@ -0,0 +1,177 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.product.AdminProductFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * [E2E 테스트 - 캐시 무효화(Eviction)] + * + * 대상: 관리자 상품 수정/삭제 후 사용자 API에서 최신 데이터가 반환되는지 검증 + * 테스트 범위: 사용자 API(HTTP) → Redis 캐시 → AdminProductFacade(evict) → 재조회 시 최신 데이터 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductCacheEvictionE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final AdminProductFacade adminProductFacade; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + private final RedisTemplate redisTemplate; + + @Autowired + public ProductCacheEvictionE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + AdminProductFacade adminProductFacade, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp, + RedisTemplate redisTemplate) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.adminProductFacade = adminProductFacade; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + this.redisTemplate = redisTemplate; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + private Brand saveActiveBrand() { + Brand brand = new Brand("테스트브랜드", "브랜드 설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name, int price) { + Product product = new Product(brand, name, price, 8000, 1000, 2500, + "상품 설명", MarginType.AMOUNT, ProductStatus.ON_SALE, "Y", List.of()); + return productJpaRepository.save(product); + } + + private ProductOption saveProductOption(Long productId) { + ProductOption option = new ProductOption(productId, "기본 옵션", 100); + return productOptionJpaRepository.save(option); + } + + @DisplayName("상품 상세 캐시 무효화") + @Nested + class ProductDetailCacheEviction { + + @Test + @DisplayName("관리자가 상품을 수정하면, 캐시가 삭제되고 사용자 API에서 최신 데이터가 반환된다.") + void returnsUpdatedData_afterAdminUpdate() { + // given - 상품 생성 + 사용자 API 호출로 캐시 적재 + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "원래 상품명", 10000); + saveProductOption(product.getId()); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // 첫 조회로 Redis 캐시 적재 + ResponseEntity> firstResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId(), + HttpMethod.GET, null, responseType); + + assertThat(firstResponse.getBody().data().name()).isEqualTo("원래 상품명"); + assertThat(redisTemplate.opsForValue().get("product:detail:" + product.getId())).isNotNull(); + + // when - 관리자가 상품명 수정 (AdminProductFacade가 evictDetail 호출) + adminProductFacade.updateProduct( + product.getId(), "수정된 상품명", 10000, 8000, 1000, 2500, + "상품 설명", ProductStatus.ON_SALE, "Y", + List.of(new ProductOption(null, "기본 옵션", 100))); + + // then - 캐시가 삭제되고, 재조회 시 최신 데이터가 반환된다 + assertThat(redisTemplate.opsForValue().get("product:detail:" + product.getId())).isNull(); + + ResponseEntity> secondResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId(), + HttpMethod.GET, null, responseType); + + assertAll( + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data().name()).isEqualTo("수정된 상품명") + ); + } + + @Test + @DisplayName("관리자가 상품을 삭제하면, 캐시가 삭제되고 사용자 API에서 404를 반환한다.") + void returns404_afterAdminDelete() { + // given - 상품 생성 + 사용자 API 호출로 캐시 적재 + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "삭제 예정 상품", 10000); + saveProductOption(product.getId()); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // 첫 조회로 Redis 캐시 적재 + ResponseEntity> firstResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId(), + HttpMethod.GET, null, responseType); + + assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(redisTemplate.opsForValue().get("product:detail:" + product.getId())).isNotNull(); + + // when - 관리자가 상품 삭제 (AdminProductFacade가 evictDetail 호출) + adminProductFacade.deleteProduct(product.getId()); + + // then - 캐시가 삭제되고, 재조회 시 404가 반환된다 + Set keys = redisTemplate.keys("product:detail:" + product.getId()); + assertThat(keys).isEmpty(); + + ResponseEntity> secondResponse = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId(), + HttpMethod.GET, null, responseType); + + assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductLocalCacheApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductLocalCacheApiE2ETest.java new file mode 100644 index 000000000..b96058883 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductLocalCacheApiE2ETest.java @@ -0,0 +1,301 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cache.CacheManager; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/* + [E2E 테스트 - 로컬 캐시(Caffeine) 적용 API] + + 대상 : 상품 목록/상세 로컬 캐시 API + 테스트 범위: HTTP 요청 → Controller → Facade(@Cacheable) → Service → Repository → Database + 검증 포인트: 캐시 Hit/Miss 동작, 캐시된 데이터 정합성, API 응답 정상 여부 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductLocalCacheApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final CacheManager cacheManager; + + @Autowired + public ProductLocalCacheApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + DatabaseCleanUp databaseCleanUp, + CacheManager cacheManager) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.databaseCleanUp = databaseCleanUp; + this.cacheManager = cacheManager; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + Objects.requireNonNull(cacheManager.getCache("productSearch")).clear(); + Objects.requireNonNull(cacheManager.getCache("productDetail")).clear(); + } + + private Brand saveActiveBrand() { + Brand brand = new Brand("테스트브랜드", "브랜드 설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name) { + Product product = new Product(brand, name, 10000, 8000, 1000, 2500, + "상품 설명", MarginType.AMOUNT, ProductStatus.ON_SALE, "Y", List.of()); + return productJpaRepository.save(product); + } + + private ProductOption saveProductOption(Long productId) { + ProductOption option = new ProductOption(productId, "기본 옵션", 100); + return productOptionJpaRepository.save(option); + } + + @DisplayName("GET /api/v1/products/local-cache") + @Nested + class GetProductsWithLocalCache { + + @Test + @DisplayName("상품 목록을 조회하면, 200 OK와 페이지 정보를 반환한다.") + void success_whenGetProducts() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/local-cache?page=0&size=10", + HttpMethod.GET, + null, + responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull()); + } + + @Test + @DisplayName("동일 조건으로 두 번 조회하면, 두 번째는 캐시에서 응답하며 결과가 동일하다.") + void success_whenCacheHit() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "캐시테스트상품"); + + String url = ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - 첫 번째 호출 (Cache Miss → DB 조회) + ResponseEntity> firstResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // when - 두 번째 호출 (Cache Hit → 캐시에서 반환) + ResponseEntity> secondResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data()) + .isEqualTo(firstResponse.getBody().data())); + } + + @Test + @DisplayName("3페이지(page=2)까지는 캐시가 적용되어 동일 결과를 반환한다.") + void success_whenPageWithinCacheLimit() { + // given + Brand brand = saveActiveBrand(); + for (int i = 0; i < 30; i++) { + saveProduct(brand, "상품" + i); + } + + String url = ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=2&size=10"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - 첫 번째 호출 (Cache Miss → DB 조회) + ResponseEntity> firstResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // when - 두 번째 호출 (Cache Hit → 캐시에서 반환) + ResponseEntity> secondResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data()) + .isEqualTo(firstResponse.getBody().data())); + } + + @Test + @DisplayName("4페이지(page=3) 이후는 캐시가 적용되지 않고 매번 DB를 조회한다.") + void success_whenPageExceedsCacheLimit() { + // given + Brand brand = saveActiveBrand(); + for (int i = 0; i < 40; i++) { + saveProduct(brand, "상품" + i); + } + + String url = ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=3&size=10"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // then - 캐시 미적용이어도 정상 응답 + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(cacheManager.getCache("productSearch").get( + "all_LATEST_all_3_10")).isNull()); + } + + @Test + @DisplayName("정렬 조건이 다르면, 각각 별도 캐시로 저장된다.") + void success_whenDifferentSortConditions() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> latestResponse = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/local-cache?sort=LATEST&page=0&size=10", + HttpMethod.GET, null, responseType); + + ResponseEntity> likeResponse = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/local-cache?sort=LIKE_DESC&page=0&size=10", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(latestResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeResponse.getStatusCode()).isEqualTo(HttpStatus.OK)); + } + } + + @DisplayName("GET /api/v1/products/{productId}/local-cache") + @Nested + class GetProductWithLocalCache { + + @Test + @DisplayName("존재하는 상품을 조회하면, 200 OK와 상품 상세 정보를 반환한다.") + void success_whenProductExists() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "테스트상품"); + saveProductOption(product.getId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/local-cache", + HttpMethod.GET, + null, + responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().name()).isEqualTo("테스트상품"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("테스트브랜드")); + } + + @Test + @DisplayName("동일 상품을 두 번 조회하면, 두 번째는 캐시에서 응답하며 결과가 동일하다.") + void success_whenCacheHit() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "캐시테스트상품"); + saveProductOption(product.getId()); + + String url = ENDPOINT_PRODUCTS + "/" + product.getId() + "/local-cache"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - 첫 번째 호출 (Cache Miss → DB 조회) + ResponseEntity> firstResponse = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // when - 두 번째 호출 (Cache Hit → 캐시에서 반환) + ResponseEntity> secondResponse = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data().name()) + .isEqualTo(firstResponse.getBody().data().name()), + () -> assertThat(secondResponse.getBody().data().price()) + .isEqualTo(firstResponse.getBody().data().price())); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND를 반환한다.") + void fail_whenProductNotFound() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/999/local-cache", + HttpMethod.GET, + null, + responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductNoCacheApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductNoCacheApiE2ETest.java new file mode 100644 index 000000000..7464bd7e1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductNoCacheApiE2ETest.java @@ -0,0 +1,227 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/* + [E2E 테스트 - 캐시 미적용 API] + + 대상 : 상품 목록/상세 API (캐시 없이 DB 직접 조회) + 테스트 범위: HTTP 요청 → Controller → Facade → Service → Repository → Database + 검증 포인트: 캐시 없이 정상 조회, Redis에 캐시가 저장되지 않음 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductNoCacheApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + private final RedisTemplate redisTemplate; + + @Autowired + public ProductNoCacheApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp, + RedisTemplate redisTemplate) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + this.redisTemplate = redisTemplate; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + private Brand saveActiveBrand() { + Brand brand = new Brand("테스트브랜드", "브랜드 설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name) { + Product product = new Product(brand, name, 10000, 8000, 1000, 2500, + "상품 설명", MarginType.AMOUNT, ProductStatus.ON_SALE, "Y", List.of()); + return productJpaRepository.save(product); + } + + private ProductOption saveProductOption(Long productId) { + ProductOption option = new ProductOption(productId, "기본 옵션", 100); + return productOptionJpaRepository.save(option); + } + + @DisplayName("GET /api/v1/products/no-cache - 상품 목록 캐시 미적용") + @Nested + class GetProductsNoCache { + + @Test + @DisplayName("상품 목록을 조회하면, 정상적으로 결과를 반환한다.") + void success_whenGetProducts_thenReturnsProducts() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품1"); + saveProduct(brand, "테스트상품2"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/no-cache?sort=LATEST&page=0&size=10", + HttpMethod.GET, null, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("상품 목록을 조회해도, Redis에 캐시가 저장되지 않는다.") + void success_whenGetProducts_thenNoCacheCreated() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/no-cache?sort=LATEST&page=0&size=10", + HttpMethod.GET, null, responseType); + + // then + Set keys = redisTemplate.keys("product:search:*"); + assertThat(keys).isEmpty(); + } + + @Test + @DisplayName("동일 조건으로 두 번 조회해도, 매번 DB에서 조회하며 결과가 동일하다.") + void success_whenCalledTwice_thenBothFromDb() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + String url = ENDPOINT_PRODUCTS + "/no-cache?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> firstResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + ResponseEntity> secondResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data()) + .isEqualTo(firstResponse.getBody().data()), + () -> assertThat(redisTemplate.keys("product:search:*")).isEmpty()); + } + } + + @DisplayName("GET /api/v1/products/{productId}/no-cache - 상품 상세 캐시 미적용") + @Nested + class GetProductNoCache { + + @Test + @DisplayName("존재하는 상품을 조회하면, 정상적으로 상세 정보를 반환한다.") + void success_whenProductExists_thenReturnsDetail() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "테스트상품"); + saveProductOption(product.getId()); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/no-cache", + HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("테스트상품")); + } + + @Test + @DisplayName("상품을 조회해도, Redis에 캐시가 저장되지 않는다.") + void success_whenProductExists_thenNoCacheCreated() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "테스트상품"); + saveProductOption(product.getId()); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/no-cache", + HttpMethod.GET, null, responseType); + + // then + Set keys = redisTemplate.keys("product:detail:*"); + assertThat(keys).isEmpty(); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND를 반환한다.") + void fail_whenProductNotFound_thenReturns404() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/999/no-cache", + HttpMethod.GET, null, responseType); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductRedisCacheApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductRedisCacheApiE2ETest.java new file mode 100644 index 000000000..423aeff06 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductRedisCacheApiE2ETest.java @@ -0,0 +1,269 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductOption; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductOptionJpaRepository; +import com.loopers.interfaces.api.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/* + [E2E 테스트 - Redis 캐시 적용 API] + + 대상 : 상품 목록/상세 API (Redis 캐시가 기존 메서드에 적용됨) + 테스트 범위: HTTP 요청 → Controller → Facade(Redis Cache) → Service → Repository → Database + 검증 포인트: 캐시 Hit/Miss 동작, 캐시 키 생성, 페이지 제한(3페이지), 캐시 무효화 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductRedisCacheApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductOptionJpaRepository productOptionJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + private final RedisCleanUp redisCleanUp; + private final RedisTemplate redisTemplate; + + @Autowired + public ProductRedisCacheApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductOptionJpaRepository productOptionJpaRepository, + DatabaseCleanUp databaseCleanUp, + RedisCleanUp redisCleanUp, + RedisTemplate redisTemplate) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productOptionJpaRepository = productOptionJpaRepository; + this.databaseCleanUp = databaseCleanUp; + this.redisCleanUp = redisCleanUp; + this.redisTemplate = redisTemplate; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + private Brand saveActiveBrand() { + Brand brand = new Brand("테스트브랜드", "브랜드 설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name) { + Product product = new Product(brand, name, 10000, 8000, 1000, 2500, + "상품 설명", MarginType.AMOUNT, ProductStatus.ON_SALE, "Y", List.of()); + return productJpaRepository.save(product); + } + + private ProductOption saveProductOption(Long productId) { + ProductOption option = new ProductOption(productId, "기본 옵션", 100); + return productOptionJpaRepository.save(option); + } + + @DisplayName("GET /api/v1/products - 상품 목록 Redis 캐시") + @Nested + class GetProductsWithRedisCache { + + @Test + @DisplayName("상품 목록을 조회하면, Redis에 캐시가 저장된다.") + void success_whenGetProducts_thenCacheCreated() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10", + HttpMethod.GET, null, responseType); + + // then + String expectedKey = "product:search:all:LATEST:all:0:10"; + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(redisTemplate.opsForValue().get(expectedKey)).isNotNull()); + } + + @Test + @DisplayName("동일 조건으로 두 번 조회하면, 두 번째는 Redis 캐시에서 응답하며 결과가 동일하다.") + void success_whenCacheHit() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "캐시테스트상품"); + + String url = ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10"; + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - 첫 번째 호출 (Cache Miss → DB 조회 → Redis 저장) + ResponseEntity> firstResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // when - 두 번째 호출 (Cache Hit → Redis에서 반환) + ResponseEntity> secondResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data()) + .isEqualTo(firstResponse.getBody().data())); + } + + @Test + @DisplayName("정렬 조건이 다르면, 각각 별도 캐시 키로 저장된다.") + void success_whenDifferentSortConditions() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=LATEST&page=0&size=10", + HttpMethod.GET, null, responseType); + + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=LIKE_DESC&page=0&size=10", + HttpMethod.GET, null, responseType); + + // then + Set keys = redisTemplate.keys("product:search:*"); + assertThat(keys).hasSize(2); + } + + @Test + @DisplayName("4페이지(page=3) 이상 조회 시 캐시에 저장하지 않는다.") + void success_whenPageExceedsLimit_thenNoCache() { + // given + Brand brand = saveActiveBrand(); + saveProduct(brand, "테스트상품"); + + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=LATEST&page=3&size=10", + HttpMethod.GET, null, responseType); + + // then + Set keys = redisTemplate.keys("product:search:*"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(keys).isEmpty()); + } + } + + @DisplayName("GET /api/v1/products/{productId} - 상품 상세 Redis 캐시") + @Nested + class GetProductWithRedisCache { + + @Test + @DisplayName("존재하는 상품을 조회하면, Redis에 캐시가 저장된다.") + void success_whenProductExists_thenCacheCreated() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "테스트상품"); + saveProductOption(product.getId()); + + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId(), + HttpMethod.GET, null, responseType); + + // then + String expectedKey = "product:detail:" + product.getId(); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().name()).isEqualTo("테스트상품"), + () -> assertThat(redisTemplate.opsForValue().get(expectedKey)).isNotNull()); + } + + @Test + @DisplayName("동일 상품을 두 번 조회하면, 두 번째는 Redis 캐시에서 응답하며 결과가 동일하다.") + void success_whenCacheHit() { + // given + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand, "캐시테스트상품"); + saveProductOption(product.getId()); + + String url = ENDPOINT_PRODUCTS + "/" + product.getId(); + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + + // when - 첫 번째 호출 (Cache Miss → DB 조회 → Redis 저장) + ResponseEntity> firstResponse = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // when - 두 번째 호출 (Cache Hit → Redis에서 반환) + ResponseEntity> secondResponse = + testRestTemplate.exchange(url, HttpMethod.GET, null, responseType); + + // then + assertAll( + () -> assertThat(firstResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(secondResponse.getBody().data().name()) + .isEqualTo(firstResponse.getBody().data().name()), + () -> assertThat(secondResponse.getBody().data().price()) + .isEqualTo(firstResponse.getBody().data().price())); + } + + @Test + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND를 반환하고 캐시에 저장하지 않는다.") + void fail_whenProductNotFound_thenNoCache() { + // when + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/999", + HttpMethod.GET, null, responseType); + + // then + Set keys = redisTemplate.keys("product:detail:*"); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(keys).isEmpty()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java index f3572bcbf..8314444f1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -238,7 +238,7 @@ void fail_whenNoAuth() { } } - @DisplayName("PUT /api/v1/products/{productId}/likes") + @DisplayName("DELETE /api/v1/products/{productId}/likes") @Nested class Unlike { @@ -263,7 +263,7 @@ void success_whenAuthenticated() { new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_PRODUCTS + "/" + product.getId() + "/likes", - HttpMethod.PUT, + HttpMethod.DELETE, new HttpEntity<>(memberAuthHeaders()), responseType); @@ -271,4 +271,77 @@ void success_whenAuthenticated() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } } + + @DisplayName("좋아요 등록/취소 시 likeCount 동기화") + @Nested + class LikeCountSync { + + @Test + @DisplayName("좋아요 등록 후 상품 조회 시, likeCount가 1 증가한다.") + void likeCountIncreases_afterLike() { + // arrange + saveMember(); + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand); + saveProductOption(product.getId()); + + // act - 좋아요 + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(memberAuthHeaders()), + new ParameterizedTypeReference>() {}); + + // assert - 상품 조회하여 likeCount 확인 (no-cache로 DB 직접 조회) + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/no-cache", + HttpMethod.GET, + null, + responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(1)); + } + + @Test + @DisplayName("좋아요 등록 후 취소하면, likeCount가 0으로 돌아온다.") + void likeCountReturnsToZero_afterLikeAndUnlike() { + // arrange + saveMember(); + Brand brand = saveActiveBrand(); + Product product = saveProduct(brand); + saveProductOption(product.getId()); + + // act - 좋아요 → 취소 + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/likes", + HttpMethod.POST, + new HttpEntity<>(memberAuthHeaders()), + new ParameterizedTypeReference>() {}); + + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/likes", + HttpMethod.DELETE, + new HttpEntity<>(memberAuthHeaders()), + new ParameterizedTypeReference>() {}); + + // assert - 상품 조회하여 likeCount 확인 (no-cache로 DB 직접 조회) + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "/" + product.getId() + "/no-cache", + HttpMethod.GET, + null, + responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0)); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerTest.java index 85f8f456a..426305b67 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerTest.java @@ -36,7 +36,7 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -155,7 +155,7 @@ void returnsUnauthorized_whenNoAuth() throws Exception { } @Nested - @DisplayName("PUT /api/v1/products/{productId}/likes") + @DisplayName("DELETE /api/v1/products/{productId}/likes") class Unlike { @Test @@ -167,7 +167,7 @@ void returnsOk_whenAuthenticated() throws Exception { when(memberService.authenticate("testuser1", "Password1!")).thenReturn(mockMember); // when & then - mockMvc.perform(put("/api/v1/products/1/likes") + mockMvc.perform(delete("/api/v1/products/1/likes") .header(HEADER_LOGIN_ID, "testuser1") .header(HEADER_LOGIN_PW, "Password1!")) .andExpect(status().isOk()); diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java index 4bec03005..ea1b7b5f0 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -70,4 +70,14 @@ public void restore() { this.deletedAt = null; } } + + /** + * 캐시 복원 시 식별자와 생성일시를 설정한다. + * JPA 영속성 컨텍스트를 거치지 않고 도메인 객체를 복원할 때 사용한다. + */ + protected void restoreBase(Long id, ZonedDateTime createdAt) { + this.id = id; + this.createdAt = createdAt; + this.updatedAt = createdAt; + } }