diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index c2ea5b3ca..a2677499c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -1,5 +1,7 @@ package com.loopers.application.point; +import java.math.BigDecimal; + import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -36,7 +38,7 @@ public PointV1Dtos.PointInfo getPointInfo(String username) { @Transactional public PointV1Dtos.PointChargeResponse chargePoint(String username, PointV1Dtos.PointChargeRequest request) { - java.math.BigDecimal totalAmount = pointService.charge(username, request.amount()); + BigDecimal totalAmount = pointService.charge(username, request.amount()); return new PointV1Dtos.PointChargeResponse(username, totalAmount); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java index 1b7012684..065177e88 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -1,6 +1,7 @@ package com.loopers.application.product; import com.loopers.application.brand.BrandInfo; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.brand.BrandEntity; import com.loopers.domain.product.ProductEntity; import com.loopers.domain.product.ProductMaterializedViewEntity; @@ -19,13 +20,18 @@ public record ProductDetailInfo( Integer stockQuantity, ProductPriceInfo price, BrandInfo brand, - Boolean isLiked // 사용자 좋아요 여부 + Boolean isLiked, // 사용자 좋아요 여부 + RankingItem ranking // 상품 랭킹 정보 (nullable) ) { /** * MV 엔티티와 좋아요 여부로 생성 (권장) */ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked) { + return from(mv, isLiked, null); + } + + public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean isLiked, RankingItem ranking) { if (mv == null) { throw new IllegalArgumentException("MV 엔티티는 필수입니다."); } @@ -44,7 +50,8 @@ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean i mv.getBrandId(), mv.getBrandName() ), - isLiked + isLiked, + ranking ); } @@ -52,6 +59,10 @@ public static ProductDetailInfo from(ProductMaterializedViewEntity mv, Boolean i * ProductEntity + BrandEntity + 좋아요수로 생성 (MV 사용 권장) */ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked) { + return of(product, brand, likeCount, isLiked, null); + } + + public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Long likeCount, Boolean isLiked, RankingItem ranking) { if (product == null) { throw new IllegalArgumentException("상품 정보는 필수입니다."); } @@ -74,7 +85,8 @@ public static ProductDetailInfo of(ProductEntity product, BrandEntity brand, Lon brand.getId(), brand.getName() ), - isLiked + isLiked, + ranking ); } @@ -87,7 +99,22 @@ public static ProductDetailInfo fromWithSyncLike(ProductDetailInfo productDetail productDetailInfo.stockQuantity(), productDetailInfo.price(), productDetailInfo.brand(), - isLiked + isLiked, + productDetailInfo.ranking() + ); + } + + public static ProductDetailInfo fromWithRanking(ProductDetailInfo productDetailInfo, RankingItem ranking) { + return new ProductDetailInfo( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + productDetailInfo.price(), + productDetailInfo.brand(), + productDetailInfo.isLiked(), + ranking ); } } 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 50c3ad916..1d912a891 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,13 +1,21 @@ package com.loopers.application.product; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.loopers.cache.CacheStrategy; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.brand.BrandEntity; import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; @@ -37,6 +45,7 @@ public class ProductFacade { private final UserService userService; private final BrandService brandService; private final UserBehaviorTracker behaviorTracker; + private final RankingRedisService rankingRedisService; /** * 도메인 서비스에서 MV 엔티티를 조회하고, Facade에서 DTO로 변환합니다. @@ -61,7 +70,7 @@ public Page getProducts(ProductSearchFilter productSearchFilter) { * 도메인 서비스에서 엔티티를 조회하고, Facade에서 DTO로 변환합니다. * * @param productId 상품 ID - * @param username 사용자명 (nullable) + * @param username 사용자 ID (nullable) * @return 상품 상세 정보 */ @Transactional(readOnly = true) @@ -96,18 +105,82 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { productCacheService.cacheProductDetail(productId, result); } - // 5. 유저 행동 추적 (상품 조회) - 캐시 히트/미스와 관계없이 항상 이벤트 발행 + // 5. 랭킹 정보 결합 (오늘 날짜 기준 실시간 순위 조회) + final RankingItem ranking = rankingRedisService.getProductRanking(LocalDate.now(), productId); + result = ProductDetailInfo.fromWithRanking(result, ranking); + + // 6. 유저 행동 추적 (이벤트 발행) if (userId != null) { - behaviorTracker.trackProductView( - userId, - productId, - null // searchKeyword는 Controller에서 받아야 함 - ); + behaviorTracker.trackProductView(userId, productId, null); } return result; } + /** + * 랭킹 상품 목록 조회 + *

+ * 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 반환 + * + * @param pageable 페이징 정보 + * @param date 조회 날짜 (null이면 오늘) + * @return 랭킹 상품 목록 + */ + @Transactional(readOnly = true) + public Page getRankingProducts(Pageable pageable, LocalDate date) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + + // 1. 랭킹 조회 + List rankings = rankingRedisService.getRanking( + targetDate, + pageable.getPageNumber() + 1, + pageable.getPageSize() + ); + + // 2. 콜드 스타트 Fallback: 오늘 랭킹이 비어있으면 어제 랭킹 조회 + if (rankings.isEmpty() && date == null) { + LocalDate yesterday = targetDate.minusDays(1); + log.info("콜드 스타트 Fallback: 오늘({}) 랭킹 없음, 어제({}) 랭킹 조회", targetDate, yesterday); + + rankings = rankingRedisService.getRanking( + yesterday, + pageable.getPageNumber() + 1, + pageable.getPageSize() + ); + + if (!rankings.isEmpty()) { + targetDate = yesterday; // totalCount 계산을 위해 날짜 변경 + } + } + + if (rankings.isEmpty()) { + log.debug("랭킹 데이터 없음: date={}", targetDate); + return Page.empty(pageable); + } + + // 3. 상품 ID 목록 추출 + List productIds = rankings.stream() + .map(RankingItem::productId) + .collect(Collectors.toList()); + + // 4. 상품 정보 조회 (MV 사용) + List products = mvService.getByIds(productIds); + + // 5. 랭킹 순서대로 정렬 + List sortedProducts = productIds.stream() + .map(productId -> products.stream() + .filter(p -> p.getProductId().equals(productId)) + .findFirst() + .map(ProductInfo::from) + .orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 6. Page 객체 생성 + long totalCount = rankingRedisService.getRankingCount(targetDate); + return new PageImpl<>(sortedProducts, pageable, totalCount); + } + /** * 상품을 삭제합니다. *

@@ -116,7 +189,7 @@ public ProductDetailInfo getProductDetail(Long productId, String username) { * @param productId 상품 ID */ @Transactional - public void deletedProduct(Long productId) { + public void deleteProduct(Long productId) { // 1. 상품 삭제 ProductEntity product = productService.getActiveProductDetail(productId); product.delete(); @@ -137,7 +210,7 @@ public void deletedProduct(Long productId) { * @param brandId 브랜드 ID */ @Transactional - public void deletedBrand(Long brandId) { + public void deleteBrand(Long brandId) { // 1. 브랜드 삭제 BrandEntity brand = brandService.getBrandById(brandId); brand.delete(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java index b669db185..9bc661921 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductMVService.java @@ -54,6 +54,16 @@ public ProductMaterializedViewEntity getById(Long productId) { )); } + /** + * 여러 상품 ID로 MV 목록을 조회합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 MV 목록 + */ + public List getByIds(List productIds) { + return mvRepository.findByIdIn(productIds); + } + /** * 브랜드별 상품 MV를 페이징 조회합니다. * diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index 1684fcd0a..daed7d359 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; + import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.PathVariable; @@ -29,6 +31,21 @@ ApiResponse> getProducts( @PageableDefault(size = 20) Pageable pageable, @RequestParam(required = false) Long brandId, @RequestParam(required = false) String productName + ); + + + @Operation( + summary = "랭킹 상품 목록 조회", + description = "일자 기준 랭킹 상품 목록을 페이징하여 조회합니다. date 파라미터가 없으면 오늘 날짜 기준으로 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청") + }) + ApiResponse> getRankingProducts( + @PageableDefault(size = 20) Pageable pageable, + @Parameter(description = "조회 날짜 (yyyy-MM-dd 형식, 선택)", example = "2025-12-23") + @RequestParam(required = false) LocalDate date ); @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 106381f81..85bac4c0c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.product; +import java.time.LocalDate; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -8,6 +10,7 @@ import com.loopers.application.product.ProductDetailInfo; import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.cache.dto.CachePayloads.RankingItem; import com.loopers.domain.product.dto.ProductSearchFilter; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.common.PageResponse; @@ -21,7 +24,6 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; - @GetMapping(Uris.Product.GET_LIST) @Override public ApiResponse> getProducts( @@ -35,6 +37,17 @@ public ApiResponse> getProducts( return ApiResponse.success(PageResponse.from(responsePage)); } + @GetMapping(Uris.Ranking.GET_RANKING) + @Override + public ApiResponse> getRankingProducts( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) LocalDate date + ) { + Page products = productFacade.getRankingProducts(pageable, date); + Page responsePage = products.map(ProductV1Dtos.ProductListResponse::from); + return ApiResponse.success(PageResponse.from(responsePage)); + } + @GetMapping(Uris.Product.GET_DETAIL) @Override public ApiResponse getProductDetail( @@ -42,8 +55,8 @@ public ApiResponse getProductDetail( @RequestHeader(value = "X-USER-ID", required = false) String username ) { ProductDetailInfo productDetail = productFacade.getProductDetail(productId, username); - ProductV1Dtos.ProductDetailResponse response = ProductV1Dtos.ProductDetailResponse.from(productDetail); - return ApiResponse.success(response); + + // 3. 응답 생성 + return ApiResponse.success(ProductV1Dtos.ProductDetailResponse.from(productDetail)); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java index 3a9893c7d..46598bd14 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dtos.java @@ -72,9 +72,20 @@ public record ProductDetailResponse( BrandDetailResponse brand, @Schema(description = "사용자의 좋아요 여부", example = "true") - Boolean isLiked + Boolean isLiked, + + @Schema(description = "랭킹 정보 (랭킹에 없으면 null)") + RankingResponse ranking ) { public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { + RankingResponse rankingResponse = null; + if (productDetailInfo.ranking() != null) { + rankingResponse = new RankingResponse( + productDetailInfo.ranking().rank(), + productDetailInfo.ranking().score() + ); + } + return new ProductDetailResponse( productDetailInfo.id(), productDetailInfo.name(), @@ -89,7 +100,28 @@ public static ProductDetailResponse from(ProductDetailInfo productDetailInfo) { productDetailInfo.brand().id(), productDetailInfo.brand().name() ), - productDetailInfo.isLiked() + productDetailInfo.isLiked(), + rankingResponse + ); + } + + public static ProductDetailResponse fromWithRanking(ProductDetailInfo productDetailInfo, RankingResponse ranking) { + return new ProductDetailResponse( + productDetailInfo.id(), + productDetailInfo.name(), + productDetailInfo.description(), + productDetailInfo.likeCount(), + productDetailInfo.stockQuantity(), + new PriceResponse( + productDetailInfo.price().originPrice(), + productDetailInfo.price().discountPrice() + ), + new BrandDetailResponse( + productDetailInfo.brand().id(), + productDetailInfo.brand().name() + ), + productDetailInfo.isLiked(), + ranking ); } } @@ -123,6 +155,16 @@ public record BrandDetailResponse( String brandName ) { } + + @Schema(description = "랭킹 정보") + public record RankingResponse( + @Schema(description = "순위", example = "1") + Long rank, + + @Schema(description = "랭킹 점수", example = "123.45") + Double score + ) { + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java index 15bfe6ca3..598e71128 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/Uris.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/Uris.java @@ -79,6 +79,17 @@ private Product() { public static final String GET_DETAIL = BASE + "/{productId}"; } + /** + * Ranking API 엔드포인트 + */ + public static class Ranking { + private Ranking() { + } + + public static final String BASE = API_V1 + "/rankings"; + public static final String GET_RANKING = BASE; + } + /** * Like API 엔드포인트 */ diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index fc52962d4..545f195a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -29,7 +29,7 @@ public enum ErrorType { //좋아요 관련 오류 ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), - NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."), // 주문 관련 오류 NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), diff --git a/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java index 7f4abb45a..fc1772312 100644 --- a/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java +++ b/apps/commerce-api/src/main/java/com/loopers/util/ProductDataGeneratorRunner.java @@ -2,6 +2,7 @@ import net.datafaker.Faker; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import org.springframework.boot.CommandLineRunner; @@ -219,7 +220,7 @@ private ProductEntity createRandomProduct(Long brandId) { BigDecimal originPrice = generateRandomPrice(10000, 500000); BigDecimal discountPrice = random.nextDouble() > 0.5 ? originPrice.multiply(BigDecimal.valueOf(random.nextDouble() * 0.8 + 0.1)) - .setScale(0, java.math.RoundingMode.HALF_UP) + .setScale(0, RoundingMode.HALF_UP) : null; int stockQuantity = random.nextInt(1000) + 1; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java new file mode 100644 index 000000000..b64e4c8e0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeRankingTest.java @@ -0,0 +1,287 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.mockito.Answers.RETURNS_DEEP_STUBS; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.*; +import com.loopers.domain.tracking.UserBehaviorTracker; +import com.loopers.domain.user.UserService; + +/** + * ProductFacade 랭킹 관련 기능 단위 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(MockitoExtension.class) +class ProductFacadeRankingTest { + + @Mock + private ProductService productService; + + @Mock + private ProductMVService mvService; + + @Mock + private ProductCacheService productCacheService; + + @Mock + private LikeService likeService; + + @Mock + private UserService userService; + + @Mock + private BrandService brandService; + + @Mock + private UserBehaviorTracker behaviorTracker; + + @Mock + private RankingRedisService rankingRedisService; + + @InjectMocks + private ProductFacade productFacade; + + private ProductMaterializedViewEntity createMockMVEntity(Long productId, String name) { + ProductMaterializedViewEntity mv = mock(ProductMaterializedViewEntity.class, RETURNS_DEEP_STUBS); + when(mv.getProductId()).thenReturn(productId); + when(mv.getName()).thenReturn(name); + when(mv.getDescription()).thenReturn("Description for " + name); + when(mv.getLikeCount()).thenReturn(10L); + when(mv.getStockQuantity()).thenReturn(100); + when(mv.getBrandId()).thenReturn(1L); + when(mv.getBrandName()).thenReturn("Test Brand"); + when(mv.getCreatedAt()).thenReturn(java.time.ZonedDateTime.now()); + + when(mv.getPrice().getOriginPrice()).thenReturn(BigDecimal.valueOf(10000)); + when(mv.getPrice().getDiscountPrice()).thenReturn(BigDecimal.valueOf(9000)); + + return mv; + } + + @Nested + @DisplayName("랭킹 상품 목록 조회 테스트") + class GetRankingProductsTest { + + @Test + @DisplayName("랭킹 순서대로 상품 목록을 조회해야 한다") + void shouldReturnProductsInRankingOrder() { + // Given + LocalDate today = LocalDate.now(); + Pageable pageable = PageRequest.of(0, 20); + + List rankings = List.of( + new RankingItem(1, 101L, 100.0), + new RankingItem(2, 102L, 90.0), + new RankingItem(3, 103L, 80.0) + ); + + List mvEntities = List.of( + createMockMVEntity(101L, "Product 101"), + createMockMVEntity(102L, "Product 102"), + createMockMVEntity(103L, "Product 103") + ); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(rankings); + when(mvService.getByIds(List.of(101L, 102L, 103L))).thenReturn(mvEntities); + when(rankingRedisService.getRankingCount(today)).thenReturn(3L); + + // When + Page result = productFacade.getRankingProducts(pageable, today); + + // Then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent().get(0).id()).isEqualTo(101L); // 1위 + assertThat(result.getContent().get(1).id()).isEqualTo(102L); // 2위 + assertThat(result.getContent().get(2).id()).isEqualTo(103L); // 3위 + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 페이지를 반환해야 한다") + void shouldReturnEmptyPageWhenNoRankingData() { + // Given + LocalDate today = LocalDate.now(); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + + // When + Page result = productFacade.getRankingProducts(pageable, today); + + // Then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @Test + @DisplayName("날짜가 null이면 오늘 날짜를 사용해야 한다") + void shouldUseTodayWhenDateIsNull() { + // Given + Pageable pageable = PageRequest.of(0, 20); + LocalDate today = LocalDate.now(); + + when(rankingRedisService.getRanking(eq(today), anyInt(), anyInt())).thenReturn(List.of()); + + // When + productFacade.getRankingProducts(pageable, null); + + // Then + verify(rankingRedisService).getRanking(eq(today), anyInt(), anyInt()); + } + } + + @Nested + @DisplayName("콜드 스타트 Fallback 테스트") + class ColdStartFallbackTest { + + @Test + @DisplayName("오늘 랭킹이 비어있으면 어제 랭킹을 조회해야 한다") + void shouldFallbackToYesterdayWhenTodayIsEmpty() { + // Given + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + Pageable pageable = PageRequest.of(0, 20); + + List yesterdayRankings = List.of( + new RankingItem(1, 201L, 50.0), + new RankingItem(2, 202L, 40.0) + ); + + List mvEntities = List.of( + createMockMVEntity(201L, "Product 201"), + createMockMVEntity(202L, "Product 202") + ); + + // 오늘 랭킹은 비어있음 + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + // 어제 랭킹은 있음 + when(rankingRedisService.getRanking(yesterday, 1, 20)).thenReturn(yesterdayRankings); + when(mvService.getByIds(List.of(201L, 202L))).thenReturn(mvEntities); + when(rankingRedisService.getRankingCount(yesterday)).thenReturn(2L); + + // When - date를 null로 전달 (오늘 날짜 사용) + Page result = productFacade.getRankingProducts(pageable, null); + + // Then - 어제 랭킹이 반환되어야 함 + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).id()).isEqualTo(201L); + assertThat(result.getContent().get(1).id()).isEqualTo(202L); + } + + @Test + @DisplayName("명시적 날짜 지정 시 Fallback하지 않아야 한다") + void shouldNotFallbackWhenDateIsExplicitlySpecified() { + // Given + LocalDate specificDate = LocalDate.now().minusDays(5); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(specificDate, 1, 20)).thenReturn(List.of()); + + // When - 명시적으로 날짜 지정 + Page result = productFacade.getRankingProducts(pageable, specificDate); + + // Then - Fallback 없이 빈 결과 반환 + assertThat(result.getContent()).isEmpty(); + // 어제 랭킹 조회 안 함 + verify(rankingRedisService, never()).getRanking(eq(specificDate.minusDays(1)), anyInt(), anyInt()); + } + + @Test + @DisplayName("오늘과 어제 모두 비어있으면 빈 페이지를 반환해야 한다") + void shouldReturnEmptyWhenBothTodayAndYesterdayAreEmpty() { + // Given + LocalDate today = LocalDate.now(); + LocalDate yesterday = today.minusDays(1); + Pageable pageable = PageRequest.of(0, 20); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(List.of()); + when(rankingRedisService.getRanking(yesterday, 1, 20)).thenReturn(List.of()); + + // When + Page result = productFacade.getRankingProducts(pageable, null); + + // Then + assertThat(result.getContent()).isEmpty(); + } + } + + @Nested + @DisplayName("상품 상세 조회 시 랭킹 정보 포함 테스트") + class ProductDetailWithRankingTest { + + @Test + @DisplayName("상품 상세 조회 시 랭킹 정보가 포함되어야 한다") + void shouldIncludeRankingInProductDetail() { + // Given + Long productId = 301L; + LocalDate today = LocalDate.now(); + + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Ranked Product"); + RankingItem ranking = new RankingItem(5, productId, 75.0); + + when(productCacheService.getProductDetailFromCache(productId)).thenReturn(Optional.empty()); + when(mvService.getById(productId)).thenReturn(mvEntity); + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(ranking); + + // When + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + assertThat(result.ranking()).isNotNull(); + assertThat(result.ranking().rank()).isEqualTo(5); + assertThat(result.ranking().score()).isEqualTo(75.0); + } + + @Test + @DisplayName("랭킹에 없는 상품은 ranking이 null이어야 한다") + void shouldHaveNullRankingForUnrankedProduct() { + // Given + Long productId = 302L; + LocalDate today = LocalDate.now(); + + ProductMaterializedViewEntity mvEntity = createMockMVEntity(productId, "Unranked Product"); + + when(productCacheService.getProductDetailFromCache(productId)).thenReturn(Optional.empty()); + when(mvService.getById(productId)).thenReturn(mvEntity); + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(null); + + // When + ProductDetailInfo result = productFacade.getProductDetail(productId, null); + + // Then + assertThat(result).isNotNull(); + assertThat(result.id()).isEqualTo(productId); + assertThat(result.ranking()).isNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java index eccbad169..2b2b30b7d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductIntegrationTest.java @@ -272,7 +272,7 @@ void throw_exception_when_product_has_non_existent_brand() { ProductEntity product = ProductTestFixture.createAndSave(productRepository, mvRepository, brand); // 브랜드 삭제 (소프트 삭제) - productFacade.deletedBrand(brand.getId()); + productFacade.deleteBrand(brand.getId()); productMVService.syncMaterializedView(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java index 8a9c07b9e..247db83fb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserUnitTest.java @@ -1,5 +1,7 @@ package com.loopers.domain.user; +import java.math.BigDecimal; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -130,13 +132,13 @@ void can_charge_point() { // given UserDomainCreateRequest userRegisterRequest = UserTestFixture.createDefaultUserDomainRequest(); UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); - java.math.BigDecimal chargeAmount = new java.math.BigDecimal("1000"); + BigDecimal chargeAmount = new BigDecimal("1000"); // when userEntity.chargePoint(chargeAmount); // then - UserTestFixture.assertUserPointAmount(userEntity, new java.math.BigDecimal("1000.00")); + UserTestFixture.assertUserPointAmount(userEntity, new BigDecimal("1000.00")); } @Test @@ -147,8 +149,8 @@ void charge_fails_with_zero_or_negative_amount() { UserEntity userEntity = UserEntity.createUserEntity(userRegisterRequest); // when & then - UserTestFixture.assertChargePointFails(userEntity, java.math.BigDecimal.ZERO, "충전 금액은 0보다 커야 합니다."); - UserTestFixture.assertChargePointFails(userEntity, new java.math.BigDecimal("-100"), "충전 금액은 0보다 커야 합니다."); + UserTestFixture.assertChargePointFails(userEntity, BigDecimal.ZERO, "충전 금액은 0보다 커야 합니다."); + UserTestFixture.assertChargePointFails(userEntity, new BigDecimal("-100"), "충전 금액은 0보다 커야 합니다."); } } } 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 705957481..c44c1358e 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 @@ -204,7 +204,7 @@ void get_products_with_default_page_size_success() { void get_products_returns_empty_list_when_no_products() { // given - 모든 상품 삭제 testProductIds.forEach(productId -> { - productFacade.deletedProduct(productId); + productFacade.deleteProduct(productId); }); productMVService.syncMaterializedView(); @@ -340,7 +340,7 @@ void get_product_detail_fail_when_product_deleted() { // given Long productId = testProductIds.get(0); ProductEntity product = productService.getActiveProductDetail(productId); - productFacade.deletedProduct(product.getId()); + productFacade.deleteProduct(product.getId()); // when diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 000000000..bb3a128e6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,462 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.junit.jupiter.api.*; +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.*; + +import com.loopers.cache.CacheKeyGenerator; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.cache.dto.CachePayloads.RankingScore.EventType; +import com.loopers.domain.brand.BrandEntity; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.*; +import com.loopers.fixtures.BrandTestFixture; +import com.loopers.fixtures.ProductTestFixture; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.ProductV1Dtos; +import com.loopers.support.Uris; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; + +/** + * 랭킹 API E2E 테스트 + *

+ * 실제 데이터 생성 → Redis ZSET 적재 → API 조회 전체 프로세스 검증 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Ranking API E2E 테스트") +class RankingV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private ProductMVService productMVService; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private CacheKeyGenerator cacheKeyGenerator; + + private final List testProductIds = new ArrayList<>(); + private LocalDate today; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + testProductIds.clear(); + Long testBrandId = null; + + today = LocalDate.now(); + + // 테스트용 브랜드 생성 + BrandEntity brand = brandService.registerBrand( + BrandTestFixture.createRequest("랭킹테스트브랜드", "랭킹 E2E 테스트용 브랜드") + ); + testBrandId = brand.getId(); + + // 테스트용 상품 5개 생성 + for (int i = 1; i <= 5; i++) { + ProductDomainCreateRequest productRequest = ProductTestFixture.createRequest( + testBrandId, + "랭킹테스트상품" + i, + "랭킹 E2E 테스트용 상품 " + i, + new BigDecimal(String.valueOf(10000 * i)), + new BigDecimal(String.valueOf(8000 * i)), + 100 + ); + ProductEntity product = productService.registerProduct(productRequest); + testProductIds.add(product.getId()); + } + + // MV 동기화 + productMVService.syncMaterializedView(); + } + + @Nested + @DisplayName("랭킹 목록 조회 API") + class GetRankingProductsTest { + + @Test + @DisplayName("랭킹 데이터가 있으면 점수 순으로 상품 목록을 반환한다") + void should_return_products_in_ranking_order() { + // Given - Redis에 랭킹 데이터 직접 적재 + Long product1 = testProductIds.get(0); // 1위 (높은 점수) + Long product2 = testProductIds.get(1); // 3위 + Long product3 = testProductIds.get(2); // 2위 + + // 점수 적재 (높은 순: product1 > product2 > product3) + List scores = List.of( + new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, System.currentTimeMillis()), + new RankingScore(product2, EventType.LIKE_ACTION, 10.0, System.currentTimeMillis()), + new RankingScore(product3, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?page=0&size=10", + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1), + () -> assertThat(response.getBody().data().content().get(2).productId()).isEqualTo(product2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(1).productId()).isEqualTo(product3) + ); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 목록을 반환한다") + void should_return_empty_when_no_ranking_data() { + // Given - 랭킹 데이터 없음 + + // When + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING, + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()).isEqualTo(0) + ); + } + + @Test + @DisplayName("페이징이 정상적으로 동작한다") + void should_paginate_ranking_results() { + // Given - 5개 상품 모두 랭킹에 등록 + List scores = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + scores.add(new RankingScore( + testProductIds.get(i), + EventType.PRODUCT_VIEW, + (5 - i) * 10.0, // 점수: 50, 40, 30, 20, 10 + System.currentTimeMillis() + )); + } + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When - 페이지 크기 2로 조회 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?page=0&size=2", + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalElements()).isEqualTo(5), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().totalPages()).isEqualTo(3), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().first()).isTrue(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().last()).isFalse() + ); + } + + @Test + @DisplayName("특정 날짜의 랭킹을 조회할 수 있다") + void should_return_ranking_for_specific_date() { + // Given - 어제 날짜에 랭킹 데이터 적재 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 100.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 어제 날짜로 조회 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?date=" + yesterday, + HttpMethod.GET, null, responseType + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1) + ); + } + } + + @Nested + @DisplayName("콜드 스타트 Fallback 테스트") + class ColdStartFallbackTest { + + @Test + @DisplayName("오늘 랭킹이 없으면 어제 랭킹을 반환한다") + void should_fallback_to_yesterday_when_today_is_empty() { + // Given - 어제 랭킹만 있음 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 날짜 미지정 (오늘 기준) + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING, + HttpMethod.GET, null, responseType + ); + + // Then - 어제 랭킹이 반환됨 + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).hasSize(1), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content().get(0).productId()).isEqualTo(product1) + ); + } + + @Test + @DisplayName("명시적 날짜 지정 시 Fallback하지 않는다") + void should_not_fallback_when_date_is_explicitly_specified() { + // Given - 어제 랭킹만 있음 + LocalDate yesterday = today.minusDays(1); + Long product1 = testProductIds.get(0); + + List scores = List.of( + new RankingScore(product1, EventType.PRODUCT_VIEW, 50.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, yesterday); + + // When - 오늘 날짜 명시적 지정 + ParameterizedTypeReference>> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity>> response = + testRestTemplate.exchange( + Uris.Ranking.GET_RANKING + "?date=" + today, + HttpMethod.GET, null, responseType + ); + + // Then - 빈 결과 (Fallback 안 함) + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().content()).isEmpty() + ); + } + } + + @Nested + @DisplayName("상품 상세 조회 시 랭킹 정보 포함 테스트") + class ProductDetailWithRankingTest { + + @Test + @DisplayName("랭킹에 있는 상품은 랭킹 정보가 포함된다") + void should_include_ranking_info_for_ranked_product() { + // Given - 상품을 랭킹에 등록 + Long productId = testProductIds.get(0); + double score = 123.45; + + List scores = List.of( + new RankingScore(productId, EventType.PAYMENT_SUCCESS, score, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().rank()).isEqualTo(1L), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().score()).isGreaterThan(0) + ); + } + + @Test + @DisplayName("랭킹에 없는 상품은 랭킹 정보가 null이다") + void should_have_null_ranking_for_unranked_product() { + // Given - 랭킹 데이터 없음 + Long productId = testProductIds.get(0); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(productId), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNull() + ); + } + + @Test + @DisplayName("여러 상품 중 특정 상품의 순위가 정확히 반환된다") + void should_return_correct_rank_among_multiple_products() { + // Given - 3개 상품 랭킹 등록 (product2가 2위) + Long product1 = testProductIds.get(0); + Long product2 = testProductIds.get(1); + Long product3 = testProductIds.get(2); + + List scores = List.of( + new RankingScore(product1, EventType.PAYMENT_SUCCESS, 100.0, System.currentTimeMillis()), + new RankingScore(product2, EventType.LIKE_ACTION, 50.0, System.currentTimeMillis()), + new RankingScore(product3, EventType.PRODUCT_VIEW, 10.0, System.currentTimeMillis()) + ); + rankingRedisService.updateRankingScoresBatch(scores, today); + + // When - product2 상세 조회 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, product2 + ); + + // Then - 2위로 반환 + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().productId()).isEqualTo(product2), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().rank()).isEqualTo(2L) + ); + } + } + + @Nested + @DisplayName("점수 누적 테스트") + class ScoreAccumulationTest { + + @Test + @DisplayName("동일 상품에 여러 이벤트 점수가 누적된다") + void should_accumulate_scores_for_same_product() { + // Given - 동일 상품에 여러 점수 적재 + Long productId = testProductIds.get(0); + + // 첫 번째 점수 적재 (PRODUCT_VIEW: weight 0.1, score 10.0 → 1.0) + rankingRedisService.updateRankingScoresBatch( + List.of(new RankingScore(productId, EventType.PRODUCT_VIEW, 10.0, System.currentTimeMillis())), + today + ); + + // 두 번째 점수 적재 (LIKE_ACTION: weight 0.2, score 20.0 → 4.0) + // 누적 점수: 1.0 + 4.0 = 5.0 + rankingRedisService.updateRankingScoresBatch( + List.of(new RankingScore(productId, EventType.LIKE_ACTION, 20.0, System.currentTimeMillis())), + today + ); + + // When + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + Uris.Product.GET_DETAIL, + HttpMethod.GET, null, responseType, productId + ); + + // Then - 점수가 누적됨 (weight 적용: 10*0.1 + 20*0.2 = 5.0) + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking()).isNotNull(), + () -> assertThat(Objects.requireNonNull(response.getBody()).data().ranking().score()).isGreaterThanOrEqualTo(5.0) + ); + } + } + + @Nested + @DisplayName("Score Carry-Over 테스트") + class CarryOverTest { + + @Test + @DisplayName("Carry-Over 후 다음 날 랭킹에 점수가 이월된다") + void should_carry_over_scores_to_next_day() { + // Given - 오늘 랭킹 데이터 직접 Redis에 적재 (weight 적용된 점수) + Long productId = testProductIds.get(0); + double weightedScore = 60.0; // PAYMENT_SUCCESS weight 0.6 * score 100 = 60 + + String todayKey = cacheKeyGenerator.generateDailyRankingKey(today); + redisTemplate.opsForZSet().add(todayKey, productId.toString(), weightedScore); + + LocalDate tomorrow = today.plusDays(1); + String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); + redisTemplate.delete(tomorrowKey); // 내일 키 정리 + + // When - Carry-Over 실행 (10%) + rankingRedisService.carryOverScores(today, tomorrow, 0.1); + + // Then - 내일 키에 10% 점수가 이월됨 + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + + assertThat(tomorrowScore).isNotNull(); + assertThat(tomorrowScore).isCloseTo(weightedScore * 0.1, org.assertj.core.data.Offset.offset(0.01)); + + // Cleanup + redisTemplate.delete(tomorrowKey); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index 4ef7cfc68..6dd5005a1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -5,11 +5,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import jakarta.annotation.PostConstruct; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java new file mode 100644 index 000000000..501d5a8c9 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/EventProcessingFacade.java @@ -0,0 +1,240 @@ +package com.loopers.application.event; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; +import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; +import com.loopers.application.metrics.MetricsService; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.domain.ranking.RankingService; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 이벤트 처리 Application Facade + *

+ * Kafka Consumer로부터 받은 이벤트를 처리하는 Application 계층 Facade입니다. + * 여러 도메인 서비스를 조합하여 이벤트를 처리합니다. + *

+ * 책임: + * - 이벤트 역직렬화 및 유효성 검증 + * - 과거 이벤트 필터링 + * - 멱등성 체크 위임 + * - 메트릭 처리 위임 + * - 랭킹 점수 생성 위임 + *

+ * 의존관계: + * - MetricsFacade (Application) - 메트릭 처리 + * - RankingService (Domain) - 랭킹 점수 생성 + * - EventDeserializer (Infrastructure) - 이벤트 역직렬화 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class EventProcessingFacade { + + // Application Layer 의존성 + private final MetricsService metricsService; + + // Domain Layer 의존성 + private final RankingService rankingService; + + // Infrastructure Layer 의존성 + private final EventDeserializer eventDeserializer; + + // 설정값 + private static final long OLD_EVENT_THRESHOLD_MS = 60 * 60 * 1000; // 1시간 + + /** + * 카탈로그 이벤트 처리 (조회, 좋아요, 재고 소진) + * + * @param eventValue 이벤트 원본 데이터 + * @return 처리 결과 (랭킹 점수 포함) + */ + public CatalogEventResult processCatalogEvent(Object eventValue) { + final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); + + if (!isValidEnvelope(envelope)) { + log.warn("Invalid event envelope: {}", eventValue); + return CatalogEventResult.notProcessed(); + } + + // 과거 이벤트 필터링 + if (isOldEvent(envelope.occurredAtEpochMillis())) { + log.debug("Ignoring old event: eventId={}, occurredAt={}", + envelope.eventId(), envelope.occurredAtEpochMillis()); + metricsService.tryMarkHandled(envelope.eventId()); + return CatalogEventResult.notProcessed(); + } + + // 멱등성 체크 + if (!metricsService.tryMarkHandled(envelope.eventId())) { + log.debug("Event already processed: {}", envelope.eventId()); + return CatalogEventResult.notProcessed(); + } + + // 이벤트 타입별 처리 + return switch (envelope.eventType()) { + case "PRODUCT_VIEW" -> processProductView(envelope); + case "LIKE_ACTION" -> processLikeAction(envelope); + case "STOCK_DEPLETED" -> processStockDepleted(envelope); + default -> { + log.debug("Unhandled catalog event type: {}", envelope.eventType()); + yield CatalogEventResult.notProcessed(); + } + }; + } + + /** + * 주문 이벤트 처리 (결제 성공) + * + * @param eventValue 이벤트 원본 데이터 + * @return 처리 결과 (랭킹 점수 포함) + */ + public OrderEventResult processOrderEvent(Object eventValue) { + final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(eventValue); + + if (!isValidEnvelope(envelope)) { + log.warn("Invalid event envelope: {}", eventValue); + return OrderEventResult.notProcessed(); + } + + // 과거 이벤트 필터링 + if (isOldEvent(envelope.occurredAtEpochMillis())) { + log.debug("Ignoring old event: eventId={}, occurredAt={}", + envelope.eventId(), envelope.occurredAtEpochMillis()); + metricsService.tryMarkHandled(envelope.eventId()); + return OrderEventResult.notProcessed(); + } + + // 멱등성 체크 + if (!metricsService.tryMarkHandled(envelope.eventId())) { + log.debug("Event already processed: {}", envelope.eventId()); + return OrderEventResult.notProcessed(); + } + + // PAYMENT_SUCCESS 이벤트만 처리 + if ("PAYMENT_SUCCESS".equals(envelope.eventType())) { + return processPaymentSuccess(envelope); + } else { + log.debug("Unhandled order event type: {}", envelope.eventType()); + return OrderEventResult.notProcessed(); + } + } + + /** + * 랭킹 점수 배치 업데이트 + * + * @param rankingScores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 + */ + public void updateRankingScores(List rankingScores, LocalDate targetDate) { + if (rankingScores == null || rankingScores.isEmpty()) { + return; + } + + try { + rankingService.updateRankingScoresBatch(rankingScores, targetDate); + log.debug("랭킹 점수 배치 업데이트 완료: {} scores", rankingScores.size()); + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: date={}, scores={}", targetDate, rankingScores.size(), e); + } + } + + // ========== Private Methods - 이벤트 타입별 처리 ========== + + private CatalogEventResult processProductView(DomainEventEnvelope envelope) { + final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("Invalid ProductView payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); + log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); + + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return CatalogEventResult.processed(rankingScore); + } + + private CatalogEventResult processLikeAction(DomainEventEnvelope envelope) { + final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.action() == null) { + log.warn("Invalid LikeAction payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + final int delta = "LIKE".equals(payload.action()) ? 1 : -1; + metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); + log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); + + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return CatalogEventResult.processed(rankingScore); + } + + private CatalogEventResult processStockDepleted(DomainEventEnvelope envelope) { + final StockDepletedPayloadV1 payload = eventDeserializer.deserializeStockDepleted(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson()); + return CatalogEventResult.notProcessed(); + } + + metricsService.handleStockDepleted( + payload.productId(), + payload.brandId(), + payload.remainingStock(), + envelope.occurredAtEpochMillis() + ); + + log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", + payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock()); + + return CatalogEventResult.notProcessed(); + } + + private OrderEventResult processPaymentSuccess(DomainEventEnvelope envelope) { + final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); + if (payload == null) { + log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson()); + return OrderEventResult.notProcessed(); + } + + if (payload.productId() == null || payload.quantity() == null || payload.quantity() <= 0) { + log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", + payload.productId(), payload.quantity()); + return OrderEventResult.notProcessed(); + } + + metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); + + log.debug("Processed PAYMENT_SUCCESS - orderId: {}, productId: {}, quantity: {}, totalPrice: {}", + payload.orderId(), payload.productId(), payload.quantity(), payload.totalPrice()); + + RankingScore rankingScore = rankingService.generateRankingScore(envelope); + return OrderEventResult.processed(rankingScore); + } + + // ========== Private Methods - 유틸리티 ========== + + private boolean isValidEnvelope(DomainEventEnvelope envelope) { + return envelope != null && envelope.eventId() != null; + } + + private boolean isOldEvent(long occurredAtEpochMillis) { + long eventAge = System.currentTimeMillis() - occurredAtEpochMillis; + return eventAge > OLD_EVENT_THRESHOLD_MS; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java b/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java new file mode 100644 index 000000000..869ac58e2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/event/dto/EventProcessingResult.java @@ -0,0 +1,44 @@ +package com.loopers.application.event.dto; + +import com.loopers.cache.dto.CachePayloads.RankingScore; + +/** + * 이벤트 처리 결과 DTO + * + * @author hyunjikoh + * @since 2025.12.26 + */ +public class EventProcessingResult { + + /** + * 카탈로그 이벤트 처리 결과 + */ + public record CatalogEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static CatalogEventResult notProcessed() { + return new CatalogEventResult(false, null); + } + + public static CatalogEventResult processed(RankingScore rankingScore) { + return new CatalogEventResult(true, rankingScore); + } + } + + /** + * 주문 이벤트 처리 결과 + */ + public record OrderEventResult( + boolean processed, + RankingScore rankingScore + ) { + public static OrderEventResult notProcessed() { + return new OrderEventResult(false, null); + } + + public static OrderEventResult processed(RankingScore rankingScore) { + return new OrderEventResult(true, rankingScore); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java new file mode 100644 index 000000000..ccae167bd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsService.java @@ -0,0 +1,207 @@ +package com.loopers.application.metrics; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +import org.springframework.stereotype.Service; + +import com.loopers.domain.event.EventHandledService; +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.infrastructure.cache.ProductCacheService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 메트릭 Application Service + *

+ * 메트릭 관련 유스케이스를 조합하는 Application 계층 서비스입니다. + * EventProcessingFacade에서 호출되어 메트릭 처리를 담당합니다. + *

+ * 책임: + * - 멱등성 체크 (메모리 캐시 + Domain Service 위임) + * - 동시성 제어 (상품별 메모리 락) + * - 메트릭 업데이트 조정 (Domain Service 위임) + * - 캐시 무효화 조정 (Infrastructure 위임) + *

+ * 의존관계: + * - ProductMetricsService (Domain) - 메트릭 비즈니스 로직 + * - EventHandledService (Domain) - 멱등성 처리 + * - ProductCacheService (Infrastructure) - 캐시 처리 + * + * @author hyunjikoh + * @since 2025. 12. 26. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MetricsService { + + // Domain Layer 의존성 + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + + // Infrastructure Layer 의존성 + private final ProductCacheService productCacheService; + + // 상품별 메모리 락 관리 + private final ConcurrentHashMap productLocks = new ConcurrentHashMap<>(); + + // 설정값 + private static final long LOCK_WAIT_TIME_MS = 100; + private static final int LOCK_CLEANUP_THRESHOLD = 10000; + + // 메모리 기반 멱등성 캐시 (빠른 경로) + private final ConcurrentHashMap processedEventsCache = new ConcurrentHashMap<>(); + private static final int PROCESSED_EVENTS_CLEANUP_THRESHOLD = 50000; + + // ========== 멱등성 체크 ========== + + /** + * 이벤트 처리 여부 확인 및 마킹 + * + * @param eventId 이벤트 ID + * @return true: 처음 처리, false: 이미 처리됨 + */ + public boolean tryMarkHandled(String eventId) { + // 1. 메모리 캐시 먼저 확인 (빠른 경로) + if (processedEventsCache.containsKey(eventId)) { + log.debug("이미 처리된 이벤트 (메모리 캐시): {}", eventId); + return false; + } + + // 2. Domain Service를 통해 DB 확인 + if (eventHandledService.isAlreadyHandled(eventId)) { + processedEventsCache.put(eventId, true); + log.debug("이미 처리된 이벤트 (DB 확인): {}", eventId); + return false; + } + + // 3. Domain Service를 통해 새로운 이벤트 저장 + boolean saved = eventHandledService.markAsHandled(eventId); + if (saved) { + processedEventsCache.put(eventId, true); + return true; + } else { + processedEventsCache.put(eventId, true); + log.debug("동시성으로 인한 중복 이벤트: {}", eventId); + return false; + } + } + + // ========== 메트릭 업데이트 ========== + + /** + * 조회수 증가 + */ + public void incrementView(Long productId, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + productMetricsService.incrementView(productId, eventTime); + log.debug("조회수 업데이트 성공: productId={}", productId); + }); + } + + /** + * 좋아요 수 변경 + */ + public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + productMetricsService.applyLikeDelta(productId, delta, eventTime); + log.debug("좋아요 수 업데이트 성공: productId={}, delta={}", productId, delta); + }); + } + + /** + * 판매량 증가 + */ + public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { + executeWithLock(productId, () -> { + ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); + boolean updated = productMetricsService.addSales(productId, quantity, eventTime); + + if (updated) { + // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) + productCacheService.onSalesCountChanged(productId); + log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); + } + }); + } + + /** + * 재고 소진 이벤트 처리 + */ + public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { + int stockToUpdate = (remainingStock != null) ? remainingStock : 0; + productCacheService.updateProductStock(productId, stockToUpdate); + log.info("재고 소진 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", + productId, brandId, stockToUpdate); + } + + // ========== Helper Methods ========== + + private void executeWithLock(Long productId, Runnable action) { + ReentrantLock lock = productLocks.computeIfAbsent(productId, k -> new ReentrantLock()); + + try { + if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { + try { + action.run(); + } finally { + lock.unlock(); + } + } else { + log.warn("메트릭 업데이트 스킵 - 락 획득 실패: productId={}", productId); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("메트릭 업데이트 중단 - 스레드 인터럽트: productId={}", productId); + } + } + + private ZonedDateTime convertToZonedDateTime(long epochMillis) { + return ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + } + + // ========== 모니터링 및 정리 ========== + + public void cleanupUnusedLocks() { + if (productLocks.size() > LOCK_CLEANUP_THRESHOLD) { + log.info("락 정리 시작 - 현재 락 수: {}", productLocks.size()); + productLocks.entrySet().removeIf(entry -> { + ReentrantLock lock = entry.getValue(); + return !lock.isLocked() && !lock.hasQueuedThreads(); + }); + log.info("락 정리 완료 - 정리 후 락 수: {}", productLocks.size()); + } + } + + public void cleanupProcessedEvents() { + if (processedEventsCache.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) { + log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEventsCache.size()); + int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2; + int toRemove = processedEventsCache.size() - targetSize; + processedEventsCache.entrySet().stream() + .limit(toRemove) + .map(Map.Entry::getKey) + .forEach(processedEventsCache::remove); + log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEventsCache.size()); + } + } + + public void logLockStatus() { + int totalLocks = productLocks.size(); + long lockedCount = productLocks.values().stream() + .filter(ReentrantLock::isLocked) + .count(); + if (totalLocks > 0) { + log.debug("메트릭 락 상태 - 총 락: {}, 사용 중: {}", totalLocks, lockedCount); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java new file mode 100644 index 000000000..3f53ab6d2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandledService.java @@ -0,0 +1,56 @@ +package com.loopers.domain.event; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 이벤트 처리 완료 Domain Service + *

+ * 이벤트 멱등성 처리를 담당하는 Domain 계층 서비스입니다. + * 이벤트 ID 기반으로 중복 처리를 방지합니다. + * + * @author hyunjikoh + * @since 2025. 12. 26. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class EventHandledService { + + private final EventRepository eventRepository; + + /** + * 이벤트가 이미 처리되었는지 확인 + */ + @Transactional(readOnly = true) + public boolean isAlreadyHandled(String eventId) { + return eventRepository.existsById(eventId); + } + + /** + * 이벤트 처리 완료 마킹 + * + * @return true: 저장 성공 (처음 처리), false: 저장 실패 (이미 처리됨 또는 동시성 충돌) + */ + @Transactional + public boolean markAsHandled(String eventId) { + try { + // 트랜잭션 내에서 다시 한번 확인 (동시성 안전) + if (eventRepository.existsById(eventId)) { + log.debug("트랜잭션 내 중복 확인: {}", eventId); + return false; + } + + eventRepository.save(EventEntity.create(eventId)); + log.debug("이벤트 처리 완료 저장: {}", eventId); + return true; + } catch (Exception e) { + // 동시성으로 인한 중복 저장 시도 (Unique 제약 조건 위반 등) + log.debug("동시성으로 인한 이벤트 저장 실패: {}", eventId, e); + return false; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java deleted file mode 100644 index 0171318e5..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricsService.java +++ /dev/null @@ -1,216 +0,0 @@ -package com.loopers.domain.metrics; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -import org.springframework.stereotype.Component; - -import com.loopers.domain.event.EventRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * ConcurrentHashMap 기반 동시성 안전한 메트릭 서비스 - *

- * 상품별 메모리 락을 사용하여 동일한 상품에 대한 동시 업데이트를 제어합니다. - * Redis 분산락 대신 메모리 기반 락을 사용하여 성능을 대폭 향상시킵니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class MetricsService { - private final EventRepository eventHandledRepository; - private final ProductMetricsService metricsTransactionService; - - // 상품별 메모리 락 관리 - private final ConcurrentHashMap productLocks = new ConcurrentHashMap<>(); - - // 락 획득 설정 (빠른 처리를 위해 짧게 설정) - private static final long LOCK_WAIT_TIME_MS = 100; // 100ms 대기 - private static final int LOCK_CLEANUP_THRESHOLD = 10000; // 락 정리 임계값 - - // 메모리 기반 멱등성 체크 (성능 최적화) - private final ConcurrentHashMap processedEvents = new ConcurrentHashMap<>(); - private static final int PROCESSED_EVENTS_CLEANUP_THRESHOLD = 50000; // 처리된 이벤트 정리 임계값 - - /** - * 멱등성 체크 - 메모리 기반으로 성능 최적화 - * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행하여 성능을 향상시킵니다. - */ - public boolean tryMarkHandled(String eventId) { - // 1. 메모리 캐시 먼저 확인 (빠른 경로) - if (processedEvents.containsKey(eventId)) { - log.debug("이미 처리된 이벤트 (메모리 캐시): {}", eventId); - return false; - } - - // 2. DB에서 확인 (느린 경로) - if (eventHandledRepository.existsById(eventId)) { - // DB에 있으면 메모리 캐시에도 추가 - processedEvents.put(eventId, true); - log.debug("이미 처리된 이벤트 (DB 확인): {}", eventId); - return false; - } - - // 3. 새로운 이벤트 - 트랜잭션 서비스를 통해 안전하게 저장 - boolean saved = metricsTransactionService.saveEventHandled(eventId); - if (saved) { - processedEvents.put(eventId, true); - return true; - } else { - // 동시성으로 인해 다른 스레드가 먼저 저장한 경우 - processedEvents.put(eventId, true); - log.debug("동시성으로 인한 중복 이벤트: {}", eventId); - return false; - } - } - - /** - * 조회수 증가 (메모리 락 적용) - */ - public void incrementView(Long productId, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.incrementViewWithTransaction(productId, occurredAtEpochMillis); - log.debug("조회수 업데이트 성공: productId={}", productId); - } finally { - lock.unlock(); - } - } else { - log.warn("조회수 업데이트 스킵 - 락 획득 실패: productId={}", productId); - // 락 획득 실패 시 이벤트 스킵 (성능 우선) - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("조회수 업데이트 중단 - 스레드 인터럽트: productId={}", productId); - } - } - - - /** - * 좋아요 수 변경 (메모리 락 적용) - */ - public void applyLikeDelta(final Long productId, final int delta, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.applyLikeDeltaWithTransaction(productId, delta, occurredAtEpochMillis); - log.debug("좋아요 수 업데이트 성공: productId={}, delta={}", productId, delta); - } finally { - lock.unlock(); - } - } else { - log.warn("좋아요 수 업데이트 스킵 - 락 획득 실패: productId={}, delta={}", productId, delta); - // 락 획득 실패 시 이벤트 스킵 (성능 우선) - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("좋아요 수 업데이트 중단 - 스레드 인터럽트: productId={}, delta={}", productId, delta); - } - } - - - /** - * 판매량 증가 (메모리 락 적용) - */ - public void addSales(final Long productId, final int quantity, long occurredAtEpochMillis) { - ReentrantLock lock = getProductLock(productId); - - try { - if (lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) { - try { - metricsTransactionService.addSalesWithTransaction(productId, quantity, occurredAtEpochMillis); - log.debug("판매량 업데이트 성공: productId={}, quantity={}", productId, quantity); - } finally { - lock.unlock(); - } - } else { - log.warn("판매량 업데이트 스킵 - 락 획득 실패: productId={}, quantity={}", productId, quantity); - - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("판매량 업데이트 중단 - 스레드 인터럽트: productId={}, quantity={}", productId, quantity); - } - } - - /** - * 재고 소진 이벤트 처리 (캐시 갱신 중심) - */ - public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { - // 재고 소진은 메트릭 업데이트보다는 캐시 갱신이 주 목적 - // 락 없이 바로 트랜잭션 서비스 호출 - metricsTransactionService.handleStockDepletedWithTransaction(productId, brandId, remainingStock, occurredAtEpochMillis); - log.info("재고 소진 이벤트 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock); - } - - /** - * 상품별 락 획득 (없으면 생성) - */ - private ReentrantLock getProductLock(Long productId) { - return productLocks.computeIfAbsent(productId, k -> new ReentrantLock()); - } - - /** - * 락 상태 모니터링 및 정리 (메모리 누수 방지) - */ - public void cleanupUnusedLocks() { - if (productLocks.size() > LOCK_CLEANUP_THRESHOLD) { - log.info("락 정리 시작 - 현재 락 수: {}", productLocks.size()); - - // 사용하지 않는 락 제거 (락이 걸려있지 않은 것들) - productLocks.entrySet().removeIf(entry -> { - ReentrantLock lock = entry.getValue(); - return !lock.isLocked() && !lock.hasQueuedThreads(); - }); - - log.info("락 정리 완료 - 정리 후 락 수: {}", productLocks.size()); - } - } - - /** - * 처리된 이벤트 캐시 정리 (메모리 누수 방지) - */ - public void cleanupProcessedEvents() { - if (processedEvents.size() > PROCESSED_EVENTS_CLEANUP_THRESHOLD) { - log.info("처리된 이벤트 캐시 정리 시작 - 현재 캐시 수: {}", processedEvents.size()); - - // 오래된 이벤트 캐시 절반 정도 제거 (LRU 방식은 아니지만 메모리 절약) - int targetSize = PROCESSED_EVENTS_CLEANUP_THRESHOLD / 2; - int currentSize = processedEvents.size(); - int toRemove = currentSize - targetSize; - - processedEvents.entrySet().stream() - .limit(toRemove) - .map(Map.Entry::getKey) - .forEach(processedEvents::remove); - - log.info("처리된 이벤트 캐시 정리 완료 - 정리 후 캐시 수: {}", processedEvents.size()); - } - } - - /** - * 락 상태 정보 조회 (모니터링용) - */ - public void logLockStatus() { - int totalLocks = productLocks.size(); - long lockedCount = productLocks.values().stream() - .mapToLong(lock -> lock.isLocked() ? 1 : 0) - .sum(); - - if (totalLocks > 0) { - log.debug("메트릭 락 상태 - 총 락: {}, 사용 중: {}", totalLocks, lockedCount); - } - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index ae4bfb12d..c9ec7e09d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -3,14 +3,23 @@ import java.util.Optional; /** + * 상품 메트릭 Repository 인터페이스 + *

+ * Domain 계층의 순수한 Repository 인터페이스입니다. + * Infrastructure 계층에서 JPA로 구현됩니다. * * @author hyunjikoh * @since 2025. 12. 16. */ public interface ProductMetricsRepository { + + /** + * 메트릭 저장 + */ ProductMetricsEntity save(ProductMetricsEntity metrics); + /** + * 상품 ID로 메트릭 조회 + */ Optional findById(Long productId); - - void deleteAll(); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index 759c3fc43..befdcf1d1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -1,112 +1,93 @@ package com.loopers.domain.metrics; +import java.time.ZonedDateTime; +import java.util.Optional; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.loopers.domain.event.EventRepository; -import com.loopers.domain.metrics.repository.MetricsRepository; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** - * 메트릭 트랜잭션 처리 서비스 + * 상품 메트릭 Domain Service *

- * Spring AOP Self-Invocation 문제를 해결하기 위해 분리된 트랜잭션 서비스입니다. - * MetricsService에서 @Transactional 메서드를 호출할 때 발생하는 문제를 방지합니다. + * 상품 메트릭 관련 비즈니스 로직을 담당하는 Domain 계층 서비스입니다. + * 트랜잭션 경계를 관리하고 Repository를 통해 데이터를 조작합니다. * * @author hyunjikoh - * @since 2025. 12. 19. + * @since 2025. 12. 26. */ @Service @RequiredArgsConstructor @Slf4j public class ProductMetricsService { - private final MetricsRepository metricsRepository; - private final EventRepository eventHandledRepository; + private final ProductMetricsRepository productMetricsRepository; /** - * 조회수 증가 (트랜잭션 적용) + * 조회수 증가 */ @Transactional - public void incrementViewWithTransaction(Long productId, long occurredAtEpochMillis) { - try { - metricsRepository.incrementView(productId, occurredAtEpochMillis); - log.debug("조회수 증가 완료: productId={}", productId); - } catch (Exception e) { - log.error("조회수 증가 실패: productId={}", productId, e); - throw e; - } + public void incrementView(Long productId, ZonedDateTime eventTime) { + ProductMetricsEntity metrics = getOrCreateMetrics(productId); + metrics.incrementView(eventTime); + productMetricsRepository.save(metrics); + log.debug("조회수 증가 완료: productId={}", productId); } /** - * 좋아요 수 변경 (트랜잭션 적용) + * 좋아요 수 변경 + * + * @return true: 변경됨, false: 변경 안 됨 (새 상품에 대한 좋아요 감소) */ @Transactional - public void applyLikeDeltaWithTransaction(Long productId, int delta, long occurredAtEpochMillis) { - try { - metricsRepository.applyLikeDelta(productId, delta, occurredAtEpochMillis); + public boolean applyLikeDelta(Long productId, int delta, ZonedDateTime eventTime) { + Optional existing = productMetricsRepository.findById(productId); + + if (existing.isPresent()) { + ProductMetricsEntity metrics = existing.get(); + metrics.applyLikeDelta(delta, eventTime); + productMetricsRepository.save(metrics); log.debug("좋아요 수 변경 완료: productId={}, delta={}", productId, delta); - } catch (Exception e) { - log.error("좋아요 수 변경 실패: productId={}, delta={}", productId, delta, e); - throw e; + return true; + } else if (delta > 0) { + // 새로운 상품에 대한 좋아요 추가만 허용 + ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); + newMetrics.applyLikeDelta(delta, eventTime); + productMetricsRepository.save(newMetrics); + log.debug("새 상품 좋아요 추가 완료: productId={}, delta={}", productId, delta); + return true; + } else { + log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta); + return false; } } /** - * 판매량 증가 (트랜잭션 적용) + * 판매량 증가 + * + * @return true: 증가됨, false: 증가 안 됨 (잘못된 수량) */ @Transactional - public void addSalesWithTransaction(Long productId, int quantity, long occurredAtEpochMillis) { - try { - metricsRepository.addSales(productId, quantity, occurredAtEpochMillis); - log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity); - } catch (Exception e) { - log.error("판매량 증가 실패: productId={}, quantity={}", productId, quantity, e); - throw e; + public boolean addSales(Long productId, int quantity, ZonedDateTime eventTime) { + if (quantity <= 0) { + log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); + return false; } - } - - - /** - * 재고 소진 이벤트 처리 (트랜잭션 적용) - * 주로 캐시 갱신을 담당합니다. - */ - @Transactional - public void handleStockDepletedWithTransaction(Long productId, Long brandId, Integer remainingStock, - long occurredAtEpochMillis) { - try { - // 재고 소진 시 캐시 갱신 처리 - metricsRepository.handleStockDepleted(productId, brandId, remainingStock, occurredAtEpochMillis); - log.debug("재고 소진 처리 완료: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock); - } catch (Exception e) { - log.error("재고 소진 처리 실패: productId={}, brandId={}, remainingStock={}", productId, brandId, remainingStock, e); - throw e; - } + ProductMetricsEntity metrics = getOrCreateMetrics(productId); + metrics.addSales(quantity, eventTime); + productMetricsRepository.save(metrics); + log.debug("판매량 증가 완료: productId={}, quantity={}", productId, quantity); + return true; } /** - * 이벤트 처리 완료 마킹 (트랜잭션 적용) - * 예외 기반이 아닌 조회 기반으로 중복 체크를 수행합니다. + * 상품 메트릭 조회 또는 생성 */ - @Transactional - public boolean saveEventHandled(String eventId) { - try { - // 트랜잭션 내에서 다시 한번 확인 (동시성 안전) - if (eventHandledRepository.existsById(eventId)) { - log.debug("트랜잭션 내 중복 확인: {}", eventId); - return false; - } - - eventHandledRepository.save(com.loopers.domain.event.EventEntity.create(eventId)); - log.debug("이벤트 처리 완료 저장: {}", eventId); - return true; - } catch (Exception e) { - // 동시성으로 인한 중복 저장 시도 (Unique 제약 조건 위반 등) - log.debug("동시성으로 인한 이벤트 저장 실패: {}", eventId, e); - return false; - } + private ProductMetricsEntity getOrCreateMetrics(Long productId) { + return productMetricsRepository.findById(productId) + .orElseGet(() -> ProductMetricsEntity.create(productId)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java deleted file mode 100644 index 7837bddaf..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/repository/MetricsRepository.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.domain.metrics.repository; - -/** - * 메트릭 업데이트를 위한 Repository 인터페이스 - *

- * 동시성 안전한 메트릭 업데이트 작업을 담당합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -public interface MetricsRepository { - - /** - * 조회수 증가 - */ - void incrementView(Long productId, long occurredAtEpochMillis); - - /** - * 좋아요 수 변경 (증가/감소) - */ - void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis); - - /** - * 판매량 증가 - */ - void addSales(Long productId, int quantity, long occurredAtEpochMillis); - - /** - * 재고 소진 이벤트 처리 (캐시 갱신 중심) - */ - void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis); -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java new file mode 100644 index 000000000..36b5d4958 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,179 @@ +package com.loopers.domain.ranking; + +import static com.loopers.support.error.ErrorType.RANKING_UPDATE_FAILED; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.support.error.CoreException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 랭킹 도메인 서비스 + *

+ * 랭킹 점수 생성, 배치 업데이트, 조회 등의 비즈니스 로직을 담당 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingService { + + private final RankingRedisService rankingRedisService; + private final EventDeserializer eventDeserializer; + + /** + * 이벤트로부터 랭킹 점수 생성 + * + * @param envelope 도메인 이벤트 엔벨로프 + * @return 랭킹 점수 (생성되지 않으면 null) + */ + public RankingScore generateRankingScore(DomainEventEnvelope envelope) { + if (envelope == null || envelope.eventType() == null) { + return null; + } + + return switch (envelope.eventType()) { + case "PRODUCT_VIEW" -> generateProductViewScore(envelope); + case "LIKE_ACTION" -> generateLikeActionScore(envelope); + case "PAYMENT_SUCCESS" -> generatePaymentSuccessScore(envelope); + default -> { + log.debug("랭킹 점수 생성 불가 - 지원하지 않는 이벤트 타입: {}", envelope.eventType()); + yield null; + } + }; + } + + /** + * 배치로 랭킹 점수 업데이트 + * + * @param rankingScores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 (null이면 각 점수의 발생 날짜 기준) + */ + public void updateRankingScoresBatch(List rankingScores, LocalDate targetDate) { + if (rankingScores == null || rankingScores.isEmpty()) { + log.debug("업데이트할 랭킹 점수가 없음"); + return; + } + + try { + if (targetDate == null) { + rankingRedisService.updateRankingScoresBatch(rankingScores); + } else { + rankingRedisService.updateRankingScoresBatch(rankingScores, targetDate); + } + log.debug("랭킹 점수 배치 업데이트 완료: {} scores, targetDate: {}", rankingScores.size(), targetDate); + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: targetDate={}, scores={}", targetDate, rankingScores.size(), e); + throw new CoreException(RANKING_UPDATE_FAILED); + } + } + + /** + * 랭킹 조회 (페이징) + * + * @param date 날짜 (null이면 오늘) + * @param page 페이지 (1부터 시작) + * @param size 페이지 크기 + * @return 랭킹 리스트 + */ + public List getRanking(LocalDate date, int page, int size) { + if (page < 1 || size < 1) { + throw new IllegalArgumentException("페이지와 크기는 1 이상이어야 합니다"); + } + + LocalDate targetDate = date != null ? date : LocalDate.now(); + + try { + return rankingRedisService.getRanking(targetDate, page, size); + } catch (Exception e) { + log.error("랭킹 조회 실패: date={}, page={}, size={}", targetDate, page, size, e); + return new ArrayList<>(); + } + } + + /** + * 특정 상품의 랭킹 조회 + * + * @param productId 상품 ID + * @param date 날짜 (null이면 오늘) + * @return 랭킹 정보 (없으면 null) + */ + public RankingItem getProductRanking(Long productId, LocalDate date) { + if (productId == null) { + return null; + } + + LocalDate targetDate = date != null ? date : LocalDate.now(); + + try { + return rankingRedisService.getProductRanking(targetDate, productId); + } catch (Exception e) { + log.error("상품 랭킹 조회 실패: productId={}, date={}", productId, targetDate, e); + return null; + } + } + + /** + * 랭킹 데이터 존재 여부 확인 + */ + public boolean hasRankingData(LocalDate date) { + LocalDate targetDate = date != null ? date : LocalDate.now(); + return rankingRedisService.hasRankingData(targetDate); + } + + // ========== Private Methods ========== + + private RankingScore generateProductViewScore(DomainEventEnvelope envelope) { + ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); + if (payload == null || payload.productId() == null) { + log.warn("상품 조회 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + return RankingScore.forProductView(payload.productId(), envelope.occurredAtEpochMillis()); + } + + private RankingScore generateLikeActionScore(DomainEventEnvelope envelope) { + LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.action() == null) { + log.warn("좋아요 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + // 좋아요만 점수에 반영, 좋아요 취소는 반영하지 않음 + if ("LIKE".equals(payload.action())) { + return RankingScore.forLikeAction(payload.productId(), envelope.occurredAtEpochMillis()); + } + + return null; + } + + private RankingScore generatePaymentSuccessScore(DomainEventEnvelope envelope) { + PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); + if (payload == null || payload.productId() == null || payload.totalPrice() == null) { + log.warn("결제 성공 이벤트 페이로드 오류: {}", envelope.payloadJson()); + return null; + } + + return RankingScore.forPaymentSuccess( + payload.productId(), + payload.totalPrice(), + envelope.occurredAtEpochMillis() + ); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java index d3785269f..cc7bbb6d7 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/event/EventRepositoryImpl.java @@ -22,6 +22,7 @@ public EventEntity save(EventEntity eventEntity) { return eventJpaRepository.save(eventEntity); } + @Override public boolean existsById(String eventId) { return eventJpaRepository.existsById(eventId); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java deleted file mode 100644 index 7296bc600..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/MetricsRepositoryImpl.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.loopers.infrastructure.metrics; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.Optional; - -import org.springframework.stereotype.Repository; - -import com.loopers.domain.metrics.ProductMetricsEntity; -import com.loopers.domain.metrics.ProductMetricsRepository; -import com.loopers.domain.metrics.repository.MetricsRepository; -import com.loopers.infrastructure.cache.ProductCacheService; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * 메트릭 Repository 구현체 - *

- * ProductMetricsRepository를 사용하여 실제 메트릭 업데이트를 수행합니다. - * 존재하지 않는 상품의 경우 새로 생성하여 처리합니다. - * - * @author hyunjikoh - * @since 2025. 12. 19. - */ -@Repository -@RequiredArgsConstructor -@Slf4j -public class MetricsRepositoryImpl implements MetricsRepository { - - private final ProductMetricsRepository productMetricsRepository; - private final ProductCacheService productCacheService; - - @Override - public void incrementView(Long productId, long occurredAtEpochMillis) { - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - long newViewCount; - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.incrementView(eventTime); - productMetricsRepository.save(metrics); - newViewCount = metrics.getViewCount(); - } else { - // 새로운 상품 메트릭 생성 - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.incrementView(eventTime); - productMetricsRepository.save(newMetrics); - newViewCount = newMetrics.getViewCount(); - } - - - log.debug("조회수 증가 완료: productId={}, eventTime={}", productId, eventTime); - } - - @Override - public void applyLikeDelta(Long productId, int delta, long occurredAtEpochMillis) { - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.applyLikeDelta(delta, eventTime); - productMetricsRepository.save(metrics); - } else { - // 새로운 상품 메트릭 생성 (좋아요가 음수가 되지 않도록 처리) - if (delta > 0) { - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.applyLikeDelta(delta, eventTime); - productMetricsRepository.save(newMetrics); - } else { - log.debug("새로운 상품에 대한 좋아요 감소 무시: productId={}, delta={}", productId, delta); - return; // 캐시 무효화 불필요 - } - } - - // 좋아요는 캐시 무효화하지 않음 (실시간 반영 불필요) - - log.debug("좋아요 수 변경 완료: productId={}, delta={}, eventTime={}", productId, delta, eventTime); - } - - @Override - public void addSales(Long productId, int quantity, long occurredAtEpochMillis) { - if (quantity <= 0) { - log.debug("잘못된 판매량 무시: productId={}, quantity={}", productId, quantity); - return; - } - - ZonedDateTime eventTime = convertToZonedDateTime(occurredAtEpochMillis); - - Optional existingMetrics = productMetricsRepository.findById(productId); - - if (existingMetrics.isPresent()) { - ProductMetricsEntity metrics = existingMetrics.get(); - metrics.addSales(quantity, eventTime); - productMetricsRepository.save(metrics); - } else { - // 새로운 상품 메트릭 생성 - ProductMetricsEntity newMetrics = ProductMetricsEntity.create(productId); - newMetrics.addSales(quantity, eventTime); - productMetricsRepository.save(newMetrics); - } - - // 캐시 무효화 (판매량 변경 - 인기 상품 순위 영향) - productCacheService.onSalesCountChanged(productId); - - log.debug("판매량 증가 완료: productId={}, quantity={}, eventTime={}", productId, quantity, eventTime); - } - - @Override - public void handleStockDepleted(Long productId, Long brandId, Integer remainingStock, long occurredAtEpochMillis) { - // 재고 소진 이벤트 처리 - // 메트릭 자체는 업데이트하지 않고 캐시만 처리 - - // 상품 상세 캐시의 재고 정보만 갱신 (빠른 응답을 위해) - int stockToUpdate = (remainingStock != null) ? remainingStock : 0; - productCacheService.updateProductStock(productId, stockToUpdate); - - log.info("재고 소진 상세 캐시 갱신 완료: productId={}, brandId={}, remainingStock={}", - productId, brandId, stockToUpdate); - } - - /** - * Epoch 밀리초를 ZonedDateTime으로 변환 - */ - private ZonedDateTime convertToZonedDateTime(long epochMillis) { - return ZonedDateTime.ofInstant( - Instant.ofEpochMilli(epochMillis), - ZoneId.systemDefault() - ); - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 4894e575e..633220ac4 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -28,9 +28,4 @@ public ProductMetricsEntity save(ProductMetricsEntity metrics) { public Optional findById(Long productId) { return productMetricsJpaRepository.findById(productId); } - - @Override - public void deleteAll() { - productMetricsJpaRepository.deleteAll(); - } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java index 4fea5785b..9d4fa5a1e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/MetricsLockCleanupScheduler.java @@ -3,7 +3,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.loopers.domain.metrics.MetricsService; +import com.loopers.application.metrics.MetricsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -58,4 +58,4 @@ public void monitorLockStatus() { log.error("락 상태 모니터링 중 오류 발생", e); } } -} \ No newline at end of file +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java new file mode 100644 index 000000000..7dd52809c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.scheduler; + +import java.time.LocalDate; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.loopers.cache.RankingRedisService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 랭킹 Score Carry-Over 스케줄러 + *

+ * 콜드 스타트 문제 해결을 위해 매일 23:50에 전날 점수의 일부를 다음 날로 이월 + * + * @author hyunjikoh + * @since 2025.12.25 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RankingCarryOverScheduler { + + private final RankingRedisService rankingRedisService; + + /** + * Carry-Over 가중치 (10%) + * - 너무 높으면: 어제 인기 상품이 계속 상위 유지 (신선도 ↓) + * - 너무 낮으면: 콜드 스타트 해결 효과 미미 + */ + private static final double CARRY_OVER_WEIGHT = 0.1; + + /** + * 매일 23:50에 실행 + *

+ * 오늘 점수의 10%를 내일 키에 미리 복사하여 자정 콜드 스타트 방지 + */ + @Scheduled(cron = "0 50 23 * * *") + public void carryOverDailyRanking() { + LocalDate today = LocalDate.now(); + LocalDate tomorrow = today.plusDays(1); + + log.info("랭킹 Carry-Over 스케줄러 시작: {} → {} (weight={})", + today, tomorrow, CARRY_OVER_WEIGHT); + + try { + long carryOverCount = rankingRedisService.carryOverScores(today, tomorrow, CARRY_OVER_WEIGHT); + + if (carryOverCount > 0) { + log.info("랭킹 Carry-Over 완료: {}개 상품 이월됨", carryOverCount); + } else { + log.warn("랭킹 Carry-Over: 이월할 데이터 없음 (오늘 랭킹이 비어있음)"); + } + + } catch (Exception e) { + log.error("랭킹 Carry-Over 실패", e); + // 스케줄러 실패 시에도 서비스는 계속 동작 (Fallback으로 대응) + } + } + + /** + * 수동 Carry-Over 실행 (테스트/운영용) + * + * @param sourceDate 원본 날짜 + * @param targetDate 대상 날짜 + * @return 이월된 상품 수 + */ + public long manualCarryOver(LocalDate sourceDate, LocalDate targetDate) { + log.info("수동 Carry-Over 실행: {} → {}", sourceDate, targetDate); + return rankingRedisService.carryOverScores(sourceDate, targetDate, CARRY_OVER_WEIGHT); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java index d5d62dbe7..18338fc7b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsKafkaConsumer.java @@ -1,26 +1,27 @@ package com.loopers.interfaces.consumer; import java.util.List; +import java.util.Objects; +import java.util.concurrent.*; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; +import com.loopers.application.event.EventProcessingFacade; +import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.confg.kafka.KafkaConfig; -import com.loopers.domain.metrics.MetricsService; -import com.loopers.infrastructure.event.DomainEventEnvelope; -import com.loopers.infrastructure.event.EventDeserializer; -import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; -import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; -import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; -import com.loopers.infrastructure.event.payloads.StockDepletedPayloadV1; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import jakarta.annotation.PreDestroy; + /** - * 메트릭스 Kafka 컨슈머 - 멱등성과 최신성을 보장하는 안전한 이벤트 처리 + * 메트릭스 Kafka 컨슈머 + *

+ * Kafka 메시지를 수신하고 EventProcessingFacade에 위임하는 인터페이스 계층 * * @author hyunjikoh * @since 2025. 12. 16. @@ -30,8 +31,14 @@ @Slf4j public class MetricsKafkaConsumer { - private final MetricsService metricsService; - private final EventDeserializer eventDeserializer; + private final EventProcessingFacade eventProcessingFacade; + // 리소스 제한이 있는 커스텀 스레드 풀 설정 + private final ExecutorService executorService = new ThreadPoolExecutor( + 20, 100, // Core, Max 스레드 + 60L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(1000), // 큐 크기 제한으로 OOM 방지 + new ThreadPoolExecutor.CallerRunsPolicy() // 큐가 꽉 차면 호출한 스레드(Kafka 리스너)가 직접 처리 + ); @KafkaListener( topics = {"catalog-events"}, @@ -43,18 +50,30 @@ public void onCatalogEvents( log.debug("Processing {} catalog events", records.size()); - for (ConsumerRecord record : records) { - try { - processCatalogEvent(record); - } catch (Exception e) { - log.error("Failed to process catalog event: {}", record.value(), e); - // 개별 메시지 실패는 로그만 남기고 계속 진행 - // 전체 배치를 실패시키지 않음 - } + // 락 프리(Lock-free) 컬렉션 사용 + final List> futures = records.stream() + .map(record -> CompletableFuture.supplyAsync(() -> { + try { + var result = eventProcessingFacade.processCatalogEvent(record.value()); + return (result.processed()) ? result.rankingScore() : null; + } catch (Exception e) { + log.error("Failed to process catalog event", e); + return null; + } + }, executorService)) + .toList(); + + // 모든 작업 완료 대기 및 결과 수집 + List rankingScores = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + + if (!rankingScores.isEmpty()) { + eventProcessingFacade.updateRankingScores(rankingScores, null); } ack.acknowledge(); - log.debug("Acknowledged {} catalog events", records.size()); } @KafkaListener( @@ -66,150 +85,45 @@ public void onOrderEvents( final Acknowledgment ack ) { - log.debug("Processing {} order events", records.size()); - - for (ConsumerRecord record : records) { - try { - processOrderEvent(record); - } catch (Exception e) { - log.error("Failed to process order event: {}", record.value(), e); - // 개별 메시지 실패는 로그만 남기고 계속 진행 - } + final List> futures = records.stream() + .map(record -> CompletableFuture.supplyAsync(() -> { + try { + var result = eventProcessingFacade.processOrderEvent(record.value()); + return (result.processed()) ? result.rankingScore() : null; + } catch (Exception e) { + log.error("Failed to process order event", e); + return null; + } + }, executorService)) + .toList(); + + // 모든 작업 완료 대기 및 결과 수집 + List rankingScores = futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList(); + + if (!rankingScores.isEmpty()) { + eventProcessingFacade.updateRankingScores(rankingScores, null); } ack.acknowledge(); log.debug("Acknowledged {} order events", records.size()); } - private void processCatalogEvent(ConsumerRecord record) { - final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); - if (envelope == null || envelope.eventId() == null) { - log.warn("Invalid event envelope: {}", record.value()); - return; - } - - // 과거 이벤트 필터링 (1시간 이상 된 이벤트는 무시) - if (isOldEvent(envelope.occurredAtEpochMillis())) { - log.debug("Ignoring old event: eventId={}, occurredAt={}", - envelope.eventId(), envelope.occurredAtEpochMillis()); - // 멱등성 테이블에는 기록하되 비즈니스 로직은 처리하지 않음 - metricsService.tryMarkHandled(envelope.eventId()); - return; - } - - // 멱등성 체크 - 이미 처리된 이벤트는 무시 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { - log.debug("Event already processed: {}", envelope.eventId()); - return; - } - - // 이벤트 타입별 처리 - switch (envelope.eventType()) { - case "PRODUCT_VIEW" -> handleProductView(envelope); - case "LIKE_ACTION" -> handleLikeAction(envelope); - case "STOCK_DEPLETED" -> handleStockDepleted(envelope); - default -> log.debug("Unhandled catalog event type: {}", envelope.eventType()); - } - } - - private void processOrderEvent(ConsumerRecord record) { - final DomainEventEnvelope envelope = eventDeserializer.deserializeEnvelope(record.value()); - if (envelope == null || envelope.eventId() == null) { - log.warn("Invalid event envelope: {}", record.value()); - return; - } - - // 과거 이벤트 필터링 - if (isOldEvent(envelope.occurredAtEpochMillis())) { - log.debug("Ignoring old event: eventId={}, occurredAt={}", - envelope.eventId(), envelope.occurredAtEpochMillis()); - metricsService.tryMarkHandled(envelope.eventId()); - return; - } - - // 멱등성 체크 - final boolean isFirstTime = metricsService.tryMarkHandled(envelope.eventId()); - if (!isFirstTime) { - log.debug("Event already processed: {}", envelope.eventId()); - return; - } - - // PAYMENT_SUCCESS 이벤트만 처리 - if ("PAYMENT_SUCCESS".equals(envelope.eventType())) { - handlePaymentSuccess(envelope); - } else { - log.debug("Unhandled order event type: {}", envelope.eventType()); - } - } - - private void handleProductView(DomainEventEnvelope envelope) { - final ProductViewPayloadV1 payload = eventDeserializer.deserializeProductView(envelope.payloadJson()); - if (payload == null || payload.productId() == null) { - log.warn("Invalid ProductView payload: {}", envelope.payloadJson()); - return; - } - - metricsService.incrementView(payload.productId(), envelope.occurredAtEpochMillis()); - log.debug("Processed PRODUCT_VIEW for productId: {}", payload.productId()); - } - - private void handleLikeAction(DomainEventEnvelope envelope) { - final LikeActionPayloadV1 payload = eventDeserializer.deserializeLikeAction(envelope.payloadJson()); - if (payload == null || payload.productId() == null || payload.action() == null) { - log.warn("Invalid LikeAction payload: {}", envelope.payloadJson()); - return; - } - - final int delta = "LIKE".equals(payload.action()) ? 1 : -1; - metricsService.applyLikeDelta(payload.productId(), delta, envelope.occurredAtEpochMillis()); - log.debug("Processed LIKE_ACTION for productId: {}, action: {}", payload.productId(), payload.action()); - } - - private void handlePaymentSuccess(DomainEventEnvelope envelope) { - final PaymentSuccessPayloadV1 payload = eventDeserializer.deserializePaymentSuccess(envelope.payloadJson()); - if (payload == null) { - log.warn("Invalid PaymentSuccess payload: {}", envelope.payloadJson()); - return; - } - - // 새로운 구조: 상품별 개별 이벤트 처리 - if (payload.productId() != null && payload.quantity() != null && payload.quantity() > 0) { - metricsService.addSales(payload.productId(), payload.quantity(), envelope.occurredAtEpochMillis()); - - log.debug( - "Processed PAYMENT_SUCCESS - orderId: {}, orderNumber: {}, userId: {}, productId: {}, quantity: {}, unitPrice: {}, totalPrice: {}", - payload.orderId(), payload.orderNumber(), payload.userId(), - payload.productId(), payload.quantity(), payload.unitPrice(), payload.totalPrice()); - } else { - log.warn("Invalid PaymentSuccess payload - missing required fields: productId={}, quantity={}", - payload.productId(), payload.quantity()); - } - } - - private void handleStockDepleted(DomainEventEnvelope envelope) { - final StockDepletedPayloadV1 payload = eventDeserializer.deserializeStockDepleted(envelope.payloadJson()); - if (payload == null || payload.productId() == null) { - log.warn("Invalid StockDepleted payload: {}", envelope.payloadJson()); - return; + @PreDestroy + public void shutdown() { + log.info("Shutting down MetricsKafkaConsumer executor..."); + executorService.shutdown(); + try { + // 작업 완료를 위해 최대 10초 대기 + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("Executor did not terminate in time, forcing shutdown"); + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); } - - // 재고 소진 이벤트 처리 - remainingStock 정보 전달 - metricsService.handleStockDepleted(payload.productId(), payload.brandId(), payload.remainingStock(), - envelope.occurredAtEpochMillis()); - - log.info("Processed STOCK_DEPLETED - productId: {}, brandId: {}, productName: {}, remainingStock: {}", - payload.productId(), payload.brandId(), payload.productName(), payload.remainingStock()); - } - - /** - * 과거 이벤트인지 확인 (1시간 이상 된 이벤트는 과거 이벤트로 간주) - */ - private boolean isOldEvent(long occurredAtEpochMillis) { - long currentTime = System.currentTimeMillis(); - long eventAge = currentTime - occurredAtEpochMillis; - long oneHourInMillis = 60 * 60 * 1000; // 1시간 - - return eventAge > oneHourInMillis; } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java new file mode 100644 index 000000000..0cc190b6b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/CoreException.java @@ -0,0 +1,19 @@ +package com.loopers.support.error; + +import lombok.Getter; + +@Getter +public class CoreException extends RuntimeException { + private final ErrorType errorType; + private final String customMessage; + + public CoreException(ErrorType errorType) { + this(errorType, null); + } + + public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java new file mode 100644 index 000000000..828f968fd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/support/error/ErrorType.java @@ -0,0 +1,53 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorType { + /** + * 범용 에러 + */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), + + // 사용자 관련 오류 + NOT_FOUND_USER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 사용자 입니다."), + + // 브랜드 관련 오류 + NOT_FOUND_BRAND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 브랜드입니다."), + DUPLICATE_BRAND(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 브랜드 이름입니다."), + + // 상품 관련 오류 + NOT_FOUND_PRODUCT(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 상품입니다."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "재고가 부족합니다."), + + //좋아요 관련 오류 + ALREADY_LIKED_PRODUCT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 좋아요한 상품입니다."), + NOT_EXIST_LIKED(HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND.getReasonPhrase(), "좋아요하지 않은 상품입니다."), + + // 주문 관련 오류 + NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 주문입니다."), + INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "유효하지 않은 주문 상태입니다."), + EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "주문 항목은 최소 1개 이상이어야 합니다."), + + // 결제 관련 오류 + PG_API_FAIL(HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY.getReasonPhrase(), "PG 결제 요청이 실패했습니다."), + INVALID_PG_RESPONSE(HttpStatus.BAD_GATEWAY, HttpStatus.BAD_GATEWAY.getReasonPhrase(), "PG 응답이 올바르지 않습니다."), + INVALID_PAYMENT_STATUS(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "알 수 없는 결제 상태입니다."), + + // 랭킹 관련 오류 + RANKING_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), + "랭킹 점수 업데이트에 실패했습니다"), + + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java new file mode 100644 index 000000000..1ba974843 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java @@ -0,0 +1,296 @@ +package com.loopers.domain.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.cache.dto.CachePayloads.RankingScore; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.EventDeserializer; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; + +/** + * 랭킹 서비스 단위 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RankingRedisService rankingRedisService; + + @Mock + private EventDeserializer eventDeserializer; + + @InjectMocks + private RankingService rankingService; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Nested + @DisplayName("랭킹 점수 생성 테스트") + class GenerateRankingScoreTest { + + @Test + @DisplayName("PRODUCT_VIEW 이벤트에서 랭킹 점수를 생성해야 한다") + void shouldGenerateScoreForProductView() throws Exception { + // Given + Long productId = 1L; + long occurredAt = System.currentTimeMillis(); + + ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-1", "PRODUCT_VIEW", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeProductView(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.PRODUCT_VIEW); + assertThat(score.score()).isEqualTo(1.0); + assertThat(score.getWeightedScore()).isEqualTo(0.1); // 0.1 * 1.0 + } + + @Test + @DisplayName("LIKE_ACTION 이벤트(좋아요)에서 랭킹 점수를 생성해야 한다") + void shouldGenerateScoreForLikeAction() throws Exception { + // Given + Long productId = 2L; + long occurredAt = System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "LIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-2", "LIKE_ACTION", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.LIKE_ACTION); + assertThat(score.getWeightedScore()).isEqualTo(0.2); // 0.2 * 1.0 + } + + @Test + @DisplayName("LIKE_ACTION 이벤트(좋아요 취소)는 랭킹 점수를 생성하지 않아야 한다") + void shouldNotGenerateScoreForUnlike() throws Exception { + // Given + Long productId = 2L; + long occurredAt = System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "UNLIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-3", "LIKE_ACTION", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializeLikeAction(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNull(); + } + + @Test + @DisplayName("PAYMENT_SUCCESS 이벤트에서 로그 정규화된 랭킹 점수를 생성해야 한다") + void shouldGenerateLogNormalizedScoreForPaymentSuccess() throws Exception { + // Given + Long productId = 3L; + long occurredAt = System.currentTimeMillis(); + BigDecimal totalPrice = BigDecimal.valueOf(10000); + + PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, productId, 2, BigDecimal.valueOf(5000), totalPrice + ); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-4", "PAYMENT_SUCCESS", "v1", occurredAt, payloadJson + ); + + when(eventDeserializer.deserializePaymentSuccess(payloadJson)).thenReturn(payload); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNotNull(); + assertThat(score.productId()).isEqualTo(productId); + assertThat(score.eventType()).isEqualTo(RankingScore.EventType.PAYMENT_SUCCESS); + + // 로그 정규화 확인: log(10000 + 1) ≈ 9.21 + double expectedScore = Math.log(10001); + assertThat(score.score()).isCloseTo(expectedScore, org.assertj.core.data.Offset.offset(0.01)); + + // 가중치 적용: 0.6 * log(10001) ≈ 5.53 + assertThat(score.getWeightedScore()).isCloseTo(0.6 * expectedScore, org.assertj.core.data.Offset.offset(0.01)); + } + + @Test + @DisplayName("지원하지 않는 이벤트 타입은 null을 반환해야 한다") + void shouldReturnNullForUnsupportedEventType() { + // Given + DomainEventEnvelope envelope = new DomainEventEnvelope( + "event-5", "UNKNOWN_EVENT", "v1", System.currentTimeMillis(), "{}" + ); + + // When + RankingScore score = rankingService.generateRankingScore(envelope); + + // Then + assertThat(score).isNull(); + } + } + + @Nested + @DisplayName("랭킹 점수 배치 업데이트 테스트") + class UpdateRankingScoresBatchTest { + + @Test + @DisplayName("랭킹 점수 리스트를 배치로 업데이트해야 한다") + void shouldUpdateRankingScoresInBatch() { + // Given + LocalDate today = LocalDate.now(); + List scores = List.of( + RankingScore.forProductView(1L, System.currentTimeMillis()), + RankingScore.forLikeAction(2L, System.currentTimeMillis()), + RankingScore.forPaymentSuccess(3L, BigDecimal.valueOf(5000), System.currentTimeMillis()) + ); + + // When + rankingService.updateRankingScoresBatch(scores, today); + + // Then + verify(rankingRedisService).updateRankingScoresBatch(scores, today); + } + + @Test + @DisplayName("빈 리스트는 업데이트하지 않아야 한다") + void shouldNotUpdateEmptyList() { + // Given + List emptyScores = List.of(); + + // When + rankingService.updateRankingScoresBatch(emptyScores, LocalDate.now()); + + // Then + verify(rankingRedisService, never()).updateRankingScoresBatch(any(), any()); + } + + @Test + @DisplayName("날짜가 null이면 각 점수의 발생 날짜 기준으로 업데이트해야 한다") + void shouldUseEventDateWhenTargetDateIsNull() { + // Given + List scores = List.of( + RankingScore.forProductView(1L, System.currentTimeMillis()) + ); + + // When + rankingService.updateRankingScoresBatch(scores, null); + + // Then - targetDate가 null이면 날짜 파라미터 없이 호출 + verify(rankingRedisService).updateRankingScoresBatch(scores); + } + } + + @Nested + @DisplayName("랭킹 조회 테스트") + class GetRankingTest { + + @Test + @DisplayName("페이징된 랭킹을 조회해야 한다") + void shouldGetPaginatedRanking() { + // Given + LocalDate today = LocalDate.now(); + List expectedRankings = List.of( + new RankingItem(1, 101L, 100.0), + new RankingItem(2, 102L, 90.0), + new RankingItem(3, 103L, 80.0) + ); + + when(rankingRedisService.getRanking(today, 1, 20)).thenReturn(expectedRankings); + + // When + List result = rankingService.getRanking(today, 1, 20); + + // Then + assertThat(result).hasSize(3); + assertThat(result.get(0).rank()).isEqualTo(1); + assertThat(result.get(0).productId()).isEqualTo(101L); + } + + @Test + @DisplayName("특정 상품의 랭킹을 조회해야 한다") + void shouldGetProductRanking() { + // Given + LocalDate today = LocalDate.now(); + Long productId = 101L; + RankingItem expectedRanking = new RankingItem(5, productId, 75.0); + + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(expectedRanking); + + // When + RankingItem result = rankingService.getProductRanking(productId, today); + + // Then + assertThat(result).isNotNull(); + assertThat(result.rank()).isEqualTo(5); + assertThat(result.productId()).isEqualTo(productId); + assertThat(result.score()).isEqualTo(75.0); + } + + @Test + @DisplayName("랭킹에 없는 상품은 null을 반환해야 한다") + void shouldReturnNullForUnrankedProduct() { + // Given + LocalDate today = LocalDate.now(); + Long productId = 999L; + + when(rankingRedisService.getProductRanking(today, productId)).thenReturn(null); + + // When + RankingItem result = rankingService.getProductRanking(productId, today); + + // Then + assertThat(result).isNull(); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java index da767237b..679ce3c79 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/MetricsEventProcessingIntegrationTest.java @@ -11,7 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.event.EventRepository; @@ -20,6 +19,8 @@ import com.loopers.infrastructure.event.DomainEventEnvelope; import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; /** * 메트릭스 이벤트 처리 통합 테스트 @@ -43,11 +44,16 @@ class MetricsEventProcessingIntegrationTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + @BeforeEach - @Transactional void setUp() { - // 테스트 데이터 정리 - productMetricsRepository.deleteAll(); + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); } @Test diff --git a/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java new file mode 100644 index 000000000..a3bd9084c --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/integration/RankingIntegrationTest.java @@ -0,0 +1,303 @@ +package com.loopers.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; +import static org.awaitility.Awaitility.await; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.cache.CacheKeyGenerator; +import com.loopers.cache.RankingRedisService; +import com.loopers.cache.dto.CachePayloads.RankingItem; +import com.loopers.config.redis.RedisConfig; +import com.loopers.infrastructure.event.DomainEventEnvelope; +import com.loopers.infrastructure.event.payloads.LikeActionPayloadV1; +import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; +import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; +import com.loopers.utils.RedisCleanUp; + +/** + * 랭킹 시스템 통합 테스트 + *

+ * Kafka 이벤트 → Redis ZSET 적재 → 랭킹 조회 E2E 테스트 + * + * @author hyunjikoh + * @since 2025.12.26 + */ +@SpringBootTest +class RankingIntegrationTest { + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Autowired + private RankingRedisService rankingRedisService; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private RedisCleanUp redisCleanUp; + + @Autowired + private CacheKeyGenerator cacheKeyGenerator; + + @Autowired + private ObjectMapper objectMapper; + + private LocalDate today; + private String todayRankingKey; + + @BeforeEach + void setUp() { + today = LocalDate.now(); + todayRankingKey = cacheKeyGenerator.generateDailyRankingKey(today); + + redisCleanUp.truncateAll(); + } + + @Nested + @DisplayName("Kafka 이벤트 → Redis ZSET 적재 테스트") + class KafkaToRedisTest { + + @Test + @DisplayName("PRODUCT_VIEW 이벤트가 랭킹 점수로 적재되어야 한다") + void shouldStoreProductViewAsRankingScore() throws Exception { + // Given + Long productId = 1001L; + String eventId = "ranking-view-test-" + System.currentTimeMillis(); + + ProductViewPayloadV1 payload = new ProductViewPayloadV1(productId, 100L); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "PRODUCT_VIEW", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("catalog-events", envelope); + + // Then - 랭킹 점수가 적재되어야 함 (Weight 0.1 * Score 1 = 0.1) + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.productId()).isEqualTo(productId); + assertThat(ranking.score()).isCloseTo(0.1, offset(0.01)); + }); + } + + @Test + @DisplayName("LIKE_ACTION 이벤트가 랭킹 점수로 적재되어야 한다") + void shouldStoreLikeActionAsRankingScore() throws Exception { + // Given + Long productId = 1002L; + String eventId = "ranking-like-test-" + System.currentTimeMillis(); + + LikeActionPayloadV1 payload = new LikeActionPayloadV1(productId, 100L, "LIKE"); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "LIKE_ACTION", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("catalog-events", envelope); + + // Then - 랭킹 점수가 적재되어야 함 (Weight 0.2 * Score 1 = 0.2) + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(0.2, offset(0.01)); + }); + } + + @Test + @DisplayName("PAYMENT_SUCCESS 이벤트가 로그 정규화된 랭킹 점수로 적재되어야 한다") + void shouldStorePaymentSuccessAsLogNormalizedScore() throws Exception { + // Given + Long productId = 1003L; + String eventId = "ranking-payment-test-" + System.currentTimeMillis(); + BigDecimal totalPrice = BigDecimal.valueOf(10000); + + PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, productId, 2, BigDecimal.valueOf(5000), totalPrice + ); + String payloadJson = objectMapper.writeValueAsString(payload); + + DomainEventEnvelope envelope = new DomainEventEnvelope( + eventId, "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), payloadJson + ); + + // When + kafkaTemplate.send("order-events", envelope); + + // Then - 로그 정규화된 점수가 적재되어야 함 + // Weight 0.6 * log(10001) ≈ 5.53 + double expectedScore = 0.6 * Math.log(10001); + + await().atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(expectedScore, offset(0.1)); + }); + } + + @Test + @DisplayName("여러 이벤트가 동일 상품에 대해 점수가 누적되어야 한다") + void shouldAccumulateScoresForSameProduct() throws Exception { + // Given + Long productId = 1004L; + long baseTime = System.currentTimeMillis(); + + // 조회 3회 + 좋아요 2회 = 0.1*3 + 0.2*2 = 0.7 + for (int i = 0; i < 3; i++) { + ProductViewPayloadV1 viewPayload = new ProductViewPayloadV1(productId, 100L); + DomainEventEnvelope viewEnvelope = new DomainEventEnvelope( + "view-" + productId + "-" + i + "-" + baseTime, + "PRODUCT_VIEW", "v1", baseTime + i, + objectMapper.writeValueAsString(viewPayload) + ); + kafkaTemplate.send("catalog-events", viewEnvelope); + } + + for (int i = 0; i < 2; i++) { + LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(productId, (long)(100 + i), "LIKE"); + DomainEventEnvelope likeEnvelope = new DomainEventEnvelope( + "like-" + productId + "-" + i + "-" + baseTime, + "LIKE_ACTION", "v1", baseTime + 10 + i, + objectMapper.writeValueAsString(likePayload) + ); + kafkaTemplate.send("catalog-events", likeEnvelope); + } + + // Then - 점수가 누적되어야 함 + double expectedScore = 0.1 * 3 + 0.2 * 2; // 0.7 + + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> { + RankingItem ranking = rankingRedisService.getProductRanking(today, productId); + assertThat(ranking).isNotNull(); + assertThat(ranking.score()).isCloseTo(expectedScore, offset(0.1)); + }); + } + } + + @Nested + @DisplayName("랭킹 조회 테스트") + class RankingQueryTest { + + @Test + @DisplayName("랭킹 순서대로 조회되어야 한다") + void shouldReturnRankingsInOrder() throws Exception { + // Given - 점수가 다른 3개 상품 등록 + Long product1 = 2001L; // 높은 점수 + Long product2 = 2002L; // 중간 점수 + Long product3 = 2003L; // 낮은 점수 + + // product1: 결제 (높은 점수) + PaymentSuccessPayloadV1 paymentPayload = new PaymentSuccessPayloadV1( + 1L, 1L, 100L, product1, 1, BigDecimal.valueOf(50000), BigDecimal.valueOf(50000) + ); + kafkaTemplate.send("order-events", new DomainEventEnvelope( + "order-" + product1 + "-" + System.currentTimeMillis(), + "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(paymentPayload) + )); + + // product2: 좋아요 (중간 점수) + LikeActionPayloadV1 likePayload = new LikeActionPayloadV1(product2, 100L, "LIKE"); + kafkaTemplate.send("catalog-events", new DomainEventEnvelope( + "like-" + product2 + "-" + System.currentTimeMillis(), + "LIKE_ACTION", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(likePayload) + )); + + // product3: 조회 (낮은 점수) + ProductViewPayloadV1 viewPayload = new ProductViewPayloadV1(product3, 100L); + kafkaTemplate.send("catalog-events", new DomainEventEnvelope( + "view-" + product3 + "-" + System.currentTimeMillis(), + "PRODUCT_VIEW", "v1", System.currentTimeMillis(), + objectMapper.writeValueAsString(viewPayload) + )); + + // Then - 점수 높은 순으로 정렬되어야 함 + await().atMost(Duration.ofSeconds(15)) + .untilAsserted(() -> { + List rankings = rankingRedisService.getRanking(today, 1, 10); + assertThat(rankings).hasSizeGreaterThanOrEqualTo(3); + + // 첫 번째가 가장 높은 점수 (결제) + assertThat(rankings.get(0).productId()).isEqualTo(product1); + // 두 번째가 중간 점수 (좋아요) + assertThat(rankings.get(1).productId()).isEqualTo(product2); + // 세 번째가 낮은 점수 (조회) + assertThat(rankings.get(2).productId()).isEqualTo(product3); + }); + } + } + + @Nested + @DisplayName("Score Carry-Over 테스트") + class CarryOverTest { + + @Test + @DisplayName("전날 점수의 일부가 다음 날로 이월되어야 한다") + void shouldCarryOverScoresToNextDay() { + // Given - 오늘 랭킹 데이터 직접 추가 + Long productId = 3001L; + double originalScore = 100.0; + + redisTemplate.opsForZSet().add(todayRankingKey, productId.toString(), originalScore); + + LocalDate tomorrow = today.plusDays(1); + String tomorrowKey = cacheKeyGenerator.generateDailyRankingKey(tomorrow); + redisTemplate.delete(tomorrowKey); // 내일 키 정리 + + // When - Carry-Over 실행 (10%) + long carryOverCount = rankingRedisService.carryOverScores(today, tomorrow, 0.1); + + // Then + assertThat(carryOverCount).isEqualTo(1); + + Double tomorrowScore = redisTemplate.opsForZSet().score(tomorrowKey, productId.toString()); + assertThat(tomorrowScore).isNotNull(); + assertThat(tomorrowScore).isCloseTo(10.0, offset(0.01)); // 100 * 0.1 + + // Cleanup + redisTemplate.delete(tomorrowKey); + } + + @Test + @DisplayName("원본 데이터가 없으면 Carry-Over를 스킵해야 한다") + void shouldSkipCarryOverWhenNoSourceData() { + // Given + LocalDate emptyDate = today.minusDays(10); + LocalDate targetDate = emptyDate.plusDays(1); + + String emptyKey = cacheKeyGenerator.generateDailyRankingKey(emptyDate); + redisTemplate.delete(emptyKey); // 확실히 비어있게 + + // When + long carryOverCount = rankingRedisService.carryOverScores(emptyDate, targetDate, 0.1); + + // Then + assertThat(carryOverCount).isEqualTo(0); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java index 99dfd0b9e..a3273bc4c 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/MetricsKafkaConsumerTest.java @@ -5,7 +5,6 @@ import java.util.List; import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,15 +13,16 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.kafka.support.Acknowledgment; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.domain.metrics.MetricsService; +import com.loopers.application.event.EventProcessingFacade; +import com.loopers.application.event.dto.EventProcessingResult.CatalogEventResult; +import com.loopers.application.event.dto.EventProcessingResult.OrderEventResult; +import com.loopers.cache.dto.CachePayloads.RankingScore; import com.loopers.infrastructure.event.DomainEventEnvelope; -import com.loopers.infrastructure.event.EventDeserializer; -import com.loopers.infrastructure.event.payloads.PaymentSuccessPayloadV1; -import com.loopers.infrastructure.event.payloads.ProductViewPayloadV1; /** * MetricsKafkaConsumer 멱등성 및 신뢰성 테스트 + *

+ * Consumer는 EventProcessingFacade에 위임만 하므로, Facade mock을 통해 테스트 * * @author hyunjikoh * @since 2025. 12. 18. @@ -31,10 +31,7 @@ class MetricsKafkaConsumerTest { @Mock - private MetricsService metricsService; - - @Mock - private EventDeserializer eventDeserializer; + private EventProcessingFacade eventProcessingFacade; @Mock private Acknowledgment acknowledgment; @@ -42,104 +39,85 @@ class MetricsKafkaConsumerTest { @InjectMocks private MetricsKafkaConsumer consumer; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - } - @Test - @DisplayName("중복된 이벤트 ID는 한 번만 처리되어야 한다") - void shouldProcessEventOnlyOnce() { + @DisplayName("카탈로그 이벤트가 Facade를 통해 처리되어야 한다") + void shouldProcessCatalogEventThroughFacade() { // Given - String eventId = "test-event-123"; DomainEventEnvelope envelope = new DomainEventEnvelope( - eventId, + "test-event-123", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "{\"productId\":1,\"userId\":100}" ); - ProductViewPayloadV1 payload = new ProductViewPayloadV1(1L, 100L); - + RankingScore rankingScore = RankingScore.forProductView(1L, System.currentTimeMillis()); ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope); - // 첫 번째 호출에서는 true (처음 처리), 두 번째 호출에서는 false (이미 처리됨) - when(metricsService.tryMarkHandled(eventId)) - .thenReturn(true) - .thenReturn(false); - - when(eventDeserializer.deserializeEnvelope(envelope)) - .thenReturn(envelope); + when(eventProcessingFacade.processCatalogEvent(envelope)) + .thenReturn(CatalogEventResult.processed(rankingScore)); - when(eventDeserializer.deserializeProductView(envelope.payloadJson())) - .thenReturn(payload); - - // When - 같은 이벤트를 두 번 처리 - consumer.onCatalogEvents(List.of(record, record), acknowledgment); + // When + consumer.onCatalogEvents(List.of(record), acknowledgment); - // Then - 비즈니스 로직은 한 번만 호출되어야 함 - verify(metricsService, times(2)).tryMarkHandled(eventId); - verify(metricsService, times(1)).incrementView(eq(1L), anyLong()); + // Then + verify(eventProcessingFacade, times(1)).processCatalogEvent(envelope); + verify(eventProcessingFacade, times(1)).updateRankingScores(anyList(), isNull()); verify(acknowledgment, times(1)).acknowledge(); } @Test - @DisplayName("잘못된 이벤트 봉투는 무시되어야 한다") - void shouldIgnoreInvalidEventEnvelope() { + @DisplayName("처리되지 않은 이벤트는 랭킹 업데이트에 포함되지 않아야 한다") + void shouldNotIncludeUnprocessedEventsInRankingUpdate() { // Given - ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, "invalid-json"); + DomainEventEnvelope envelope = new DomainEventEnvelope( + "test-event-456", + "UNKNOWN_EVENT", + "v1", + System.currentTimeMillis(), + "{}" + ); + + ConsumerRecord record = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope); - when(eventDeserializer.deserializeEnvelope("invalid-json")) - .thenReturn(null); + when(eventProcessingFacade.processCatalogEvent(envelope)) + .thenReturn(CatalogEventResult.notProcessed()); // When consumer.onCatalogEvents(List.of(record), acknowledgment); // Then - verify(metricsService, never()).tryMarkHandled(anyString()); - verify(metricsService, never()).incrementView(anyLong(), anyLong()); - verify(acknowledgment, times(1)).acknowledge(); // 배치는 여전히 ack 되어야 함 + verify(eventProcessingFacade, times(1)).processCatalogEvent(envelope); + verify(eventProcessingFacade, never()).updateRankingScores(anyList(), any()); + verify(acknowledgment, times(1)).acknowledge(); } @Test - @DisplayName("PAYMENT_SUCCESS 이벤트가 상품별로 개별 처리되어야 한다") - void shouldProcessPaymentSuccessEvent() { + @DisplayName("PAYMENT_SUCCESS 이벤트가 Facade를 통해 처리되어야 한다") + void shouldProcessPaymentSuccessEventThroughFacade() { // Given - String eventId = "payment-event-456"; DomainEventEnvelope envelope = new DomainEventEnvelope( - eventId, + "payment-event-789", "PAYMENT_SUCCESS", "v1", System.currentTimeMillis(), - "{\"orderId\":12345,\"orderNumber\":67890,\"userId\":100,\"productId\":1,\"quantity\":2,\"unitPrice\":1000,\"totalPrice\":2000}" + "{\"orderId\":12345,\"productId\":1,\"quantity\":2,\"totalPrice\":2000}" ); - // 새로운 PaymentSuccessPayloadV1 구조 (상품별 개별 이벤트) - PaymentSuccessPayloadV1 payload = new PaymentSuccessPayloadV1( - 12345L, // orderId - 67890L, // orderNumber - 100L, // userId - 1L, // productId - 2, // quantity - java.math.BigDecimal.valueOf(1000), // unitPrice - java.math.BigDecimal.valueOf(2000) // totalPrice + RankingScore rankingScore = RankingScore.forPaymentSuccess( + 1L, java.math.BigDecimal.valueOf(2000), System.currentTimeMillis() ); - ConsumerRecord record = new ConsumerRecord<>("order-events", 0, 0, null, envelope); - when(metricsService.tryMarkHandled(eventId)).thenReturn(true); - when(eventDeserializer.deserializeEnvelope(envelope)).thenReturn(envelope); - when(eventDeserializer.deserializePaymentSuccess(envelope.payloadJson())).thenReturn(payload); + when(eventProcessingFacade.processOrderEvent(envelope)) + .thenReturn(OrderEventResult.processed(rankingScore)); // When consumer.onOrderEvents(List.of(record), acknowledgment); // Then - verify(metricsService, times(1)).tryMarkHandled(eventId); - verify(metricsService, times(1)).addSales(eq(1L), eq(2), anyLong()); + verify(eventProcessingFacade, times(1)).processOrderEvent(envelope); + verify(eventProcessingFacade, times(1)).updateRankingScores(anyList(), isNull()); verify(acknowledgment, times(1)).acknowledge(); } @@ -147,11 +125,8 @@ void shouldProcessPaymentSuccessEvent() { @DisplayName("개별 메시지 처리 실패가 전체 배치를 실패시키지 않아야 한다") void shouldContinueProcessingWhenIndividualMessageFails() { // Given - String validEventId = "valid-event"; - String invalidEventId = "invalid-event"; - DomainEventEnvelope validEnvelope = new DomainEventEnvelope( - validEventId, + "valid-event", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), @@ -159,32 +134,61 @@ void shouldContinueProcessingWhenIndividualMessageFails() { ); DomainEventEnvelope invalidEnvelope = new DomainEventEnvelope( - invalidEventId, + "invalid-event", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "invalid-payload" ); - ProductViewPayloadV1 validPayload = new ProductViewPayloadV1(1L, 100L); + RankingScore rankingScore = RankingScore.forProductView(1L, System.currentTimeMillis()); ConsumerRecord validRecord = new ConsumerRecord<>("catalog-events", 0, 0, null, validEnvelope); ConsumerRecord invalidRecord = new ConsumerRecord<>("catalog-events", 0, 1, null, invalidEnvelope); - when(metricsService.tryMarkHandled(validEventId)).thenReturn(true); - when(metricsService.tryMarkHandled(invalidEventId)).thenReturn(true); - - when(eventDeserializer.deserializeEnvelope(validEnvelope)).thenReturn(validEnvelope); - when(eventDeserializer.deserializeEnvelope(invalidEnvelope)).thenReturn(invalidEnvelope); - - when(eventDeserializer.deserializeProductView(validEnvelope.payloadJson())).thenReturn(validPayload); - when(eventDeserializer.deserializeProductView(invalidEnvelope.payloadJson())).thenReturn(null); // 파싱 실패 + when(eventProcessingFacade.processCatalogEvent(validEnvelope)) + .thenReturn(CatalogEventResult.processed(rankingScore)); + when(eventProcessingFacade.processCatalogEvent(invalidEnvelope)) + .thenThrow(new RuntimeException("Processing failed")); // When consumer.onCatalogEvents(List.of(validRecord, invalidRecord), acknowledgment); // Then - 유효한 메시지는 처리되고, 전체 배치는 ack 되어야 함 - verify(metricsService, times(1)).incrementView(eq(1L), anyLong()); + verify(eventProcessingFacade, times(1)).updateRankingScores( + argThat(list -> list.size() == 1), + isNull() + ); + verify(acknowledgment, times(1)).acknowledge(); + } + + @Test + @DisplayName("여러 이벤트의 랭킹 점수가 배치로 업데이트되어야 한다") + void shouldBatchUpdateRankingScores() { + // Given + DomainEventEnvelope envelope1 = new DomainEventEnvelope( + "event-1", "PRODUCT_VIEW", "v1", System.currentTimeMillis(), "{\"productId\":1}" + ); + DomainEventEnvelope envelope2 = new DomainEventEnvelope( + "event-2", "LIKE_ACTION", "v1", System.currentTimeMillis(), "{\"productId\":2,\"action\":\"LIKE\"}" + ); + + RankingScore score1 = RankingScore.forProductView(1L, System.currentTimeMillis()); + RankingScore score2 = RankingScore.forLikeAction(2L, System.currentTimeMillis()); + + ConsumerRecord record1 = new ConsumerRecord<>("catalog-events", 0, 0, null, envelope1); + ConsumerRecord record2 = new ConsumerRecord<>("catalog-events", 0, 1, null, envelope2); + + when(eventProcessingFacade.processCatalogEvent(envelope1)) + .thenReturn(CatalogEventResult.processed(score1)); + when(eventProcessingFacade.processCatalogEvent(envelope2)) + .thenReturn(CatalogEventResult.processed(score2)); + + // When + consumer.onCatalogEvents(List.of(record1, record2), acknowledgment); + + // Then - 2개의 랭킹 점수가 배치로 업데이트되어야 함 + verify(eventProcessingFacade, times(1)).updateRankingScores(argThat(list -> list.size() == 2), isNull()); verify(acknowledgment, times(1)).acknowledge(); } } diff --git a/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java b/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java index b5df4dc32..438fb1e07 100644 --- a/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java +++ b/modules/jpa/src/main/generated/com/loopers/domain/QBaseEntity.java @@ -13,7 +13,7 @@ * QBaseEntity is a Querydsl query type for BaseEntity */ @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") -public class QBaseEntity extends EntityPathBase { +public class QBaseEntity extends EntityPathBase> { private static final long serialVersionUID = 1030422725L; @@ -27,16 +27,19 @@ public class QBaseEntity extends EntityPathBase { public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.ZonedDateTime.class); + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(String variable) { - super(BaseEntity.class, forVariable(variable)); + super((Class) BaseEntity.class, forVariable(variable)); } + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(Path path) { - super(path.getType(), path.getMetadata()); + super((Class) path.getType(), path.getMetadata()); } + @SuppressWarnings({"all", "rawtypes", "unchecked"}) public QBaseEntity(PathMetadata metadata) { - super(BaseEntity.class, metadata); + super((Class) BaseEntity.class, metadata); } } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java index 5b5e9423f..4269024ed 100644 --- a/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKeyGenerator.java @@ -1,5 +1,7 @@ package com.loopers.cache; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.StringJoiner; import org.springframework.data.domain.Pageable; @@ -28,6 +30,10 @@ public class CacheKeyGenerator { public static final String METRICS_PREFIX = "metrics"; public static final String POPULAR_PREFIX = "popular"; public static final String LIST_PREFIX = "list"; + public static final String RANKING_PREFIX = "ranking"; + public static final String ALL_PREFIX = "all"; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); /** * 상품 상세 캐시 키: product:detail:{productId} @@ -127,4 +133,35 @@ private String generateSortString(Sort sort) { return sortJoiner.toString(); } + + /** + * 일간 랭킹 키 생성: ranking:all:20251223 + */ + public String generateDailyRankingKey(LocalDate date) { + return new StringJoiner(DELIMITER) + .add(RANKING_PREFIX) + .add(ALL_PREFIX) + .add(date.format(DATE_FORMATTER)) + .toString(); + } + + /** + * 현재 날짜 기준 랭킹 키 생성 + */ + public String generateTodayRankingKey() { + return generateDailyRankingKey(LocalDate.now()); + } + + /** + * 키에서 날짜 추출 + */ + public LocalDate extractDateFromKey(String key) { + String expectedPrefix = RANKING_PREFIX + DELIMITER + ALL_PREFIX + DELIMITER; + if (!key.startsWith(expectedPrefix)) { + throw new IllegalArgumentException("Invalid ranking key format: " + key); + } + + String dateStr = key.substring(expectedPrefix.length()); + return LocalDate.parse(dateStr, DATE_FORMATTER); + } } diff --git a/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java new file mode 100644 index 000000000..bbfaab689 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/RankingRedisService.java @@ -0,0 +1,291 @@ +package com.loopers.cache; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + + +import com.loopers.cache.dto.CachePayloads; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Redis ZSET을 이용한 랭킹 시스템 서비스 + * + * @author hyunjikoh + * @since 2025.12.23 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingRedisService { + + private final RedisTemplate redisTemplate; + private final CacheKeyGenerator cacheKeyGenerator; + + private static final Duration RANKING_TTL = Duration.ofDays(2); // 2일 TTL + + /** + * 배치로 랭킹 점수 업데이트 (여러 날짜의 점수 포함 가능) + * + * @param scores 랭킹 점수 리스트 + */ + public void updateRankingScoresBatch(List scores) { + if (scores.isEmpty()) { + return; + } + + try { + // 날짜별로 그룹화 + Map> scoresByDate = scores.stream() + .collect(Collectors.groupingBy(CachePayloads.RankingScore::getEventDate)); + + for (Map.Entry> entry : scoresByDate.entrySet()) { + LocalDate date = entry.getKey(); + List dateScores = entry.getValue(); + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + + // 해당 날짜의 상품별 점수 집계 + Map productScores = dateScores.stream() + .collect(Collectors.groupingBy( + CachePayloads.RankingScore::productId, + Collectors.summingDouble(CachePayloads.RankingScore::getWeightedScore) + )); + + // Redis Pipeline 사용 + redisTemplate.executePipelined((RedisCallback) connection -> { + productScores.forEach((productId, totalScore) -> { + redisTemplate.opsForZSet().incrementScore(rankingKey, productId.toString(), totalScore); + }); + return null; + }); + + // TTL 설정 + redisTemplate.expire(rankingKey, RANKING_TTL); + + log.debug("랭킹 점수 배치 업데이트 완료: key={}, products={}", + rankingKey, productScores.size()); + } + + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패", e); + throw new RuntimeException("랭킹 업데이트 실패", e); + } + } + + /** + * 특정 날짜에 대해 배치로 랭킹 점수 업데이트 (하위 호환성 유지) + * + * @param scores 랭킹 점수 리스트 + * @param targetDate 대상 날짜 + */ + public void updateRankingScoresBatch(List scores, LocalDate targetDate) { + if (scores.isEmpty()) { + return; + } + + if (targetDate == null) { + updateRankingScoresBatch(scores); + return; + } + + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(targetDate); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 상품별로 점수 집계 (가중치 적용) + Map productScores = scores.stream() + .collect(Collectors.groupingBy( + CachePayloads.RankingScore::productId, + Collectors.summingDouble(CachePayloads.RankingScore::getWeightedScore) + )); + + // Redis Pipeline을 사용한 배치 업데이트 + redisTemplate.executePipelined((RedisCallback) connection -> { + productScores.forEach((productId, totalScore) -> { + zSetOps.incrementScore(rankingKey, productId.toString(), totalScore); + }); + return null; + }); + + // TTL 설정 + redisTemplate.expire(rankingKey, RANKING_TTL); + + log.debug("랭킹 점수 배치 업데이트 완료: key={}, products={}", + rankingKey, productScores.size()); + + } catch (Exception e) { + log.error("랭킹 점수 배치 업데이트 실패: key={}", rankingKey, e); + throw new RuntimeException("랭킹 업데이트 실패", e); + } + } + + /** + * 랭킹 조회 (페이징) + * + * @param date 날짜 + * @param page 페이지 (1부터 시작) + * @param size 페이지 크기 + * @return 랭킹 리스트 (상위부터) + */ + public List getRanking(LocalDate date, int page, int size) { + // 1-based index (API)를 0-based index (Redis)로 변환하는 로직을 서비스 내부로 캡슐화 + int pageForRedis = Math.max(1, page); + + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 페이징 계산 (Redis는 0부터 시작) + long start = (long) (pageForRedis - 1) * size; + long end = start + size - 1; + + // 점수 높은 순으로 조회 (ZREVRANGE) + Set> rankingData = + zSetOps.reverseRangeWithScores(rankingKey, start, end); + + if (rankingData == null || rankingData.isEmpty()) { + log.debug("랭킹 데이터 없음: key={}, page={}, size={}", rankingKey, page, size); + return List.of(); + } + + // 순위 계산 (페이징 고려) + List result = new java.util.ArrayList<>(); + long currentRank = start + 1; // 1부터 시작하는 순위 + + for (ZSetOperations.TypedTuple tuple : rankingData) { + try { + Long productId = Long.parseLong(tuple.getValue()); + Double score = tuple.getScore(); + result.add(new CachePayloads.RankingItem(currentRank, productId, score)); + currentRank++; + } catch (NumberFormatException e) { + log.warn("잘못된 상품 ID 형식: {}", tuple.getValue(), e); + } + } + + log.debug("랭킹 조회 완료: key={}, page={}, size={}, results={}", + rankingKey, page, size, result.size()); + + return result; + + } catch (Exception e) { + log.error("랭킹 조회 실패: key={}, page={}, size={}", rankingKey, page, size, e); + return List.of(); + } + } + + /** + * 특정 상품의 랭킹 조회 + * + * @param date 날짜 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 null) + */ + public CachePayloads.RankingItem getProductRanking(LocalDate date, Long productId) { + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + try { + // 점수 조회 + Double score = zSetOps.score(rankingKey, productId.toString()); + if (score == null) { + log.debug("상품 랭킹 없음: key={}, productId={}", rankingKey, productId); + return null; + } + + // 순위 조회 (높은 점수부터 순위 매김) + Long rank = zSetOps.reverseRank(rankingKey, productId.toString()); + if (rank == null) { + log.warn("점수는 있지만 순위 조회 실패: key={}, productId={}", rankingKey, productId); + return null; + } + + CachePayloads.RankingItem result = new CachePayloads.RankingItem(rank + 1, productId, score); // Redis rank는 0부터 시작 + log.debug("상품 랭킹 조회 완료: {}", result); + + return result; + + } catch (Exception e) { + log.error("상품 랭킹 조회 실패: key={}, productId={}", rankingKey, productId, e); + return null; + } + } + + /** + * 랭킹 전체 개수 조회 + */ + public long getRankingCount(LocalDate date) { + String rankingKey = cacheKeyGenerator.generateDailyRankingKey(date); + Long count = redisTemplate.opsForZSet().zCard(rankingKey); + return count != null ? count : 0L; + } + + /** + * 랭킹 데이터 존재 여부 확인 + */ + public boolean hasRankingData(LocalDate date) { + return getRankingCount(date) > 0; + } + + /** + * Score Carry-Over: 전날 점수의 일부를 다음 날로 이월 + *

+ * 전날 점수에 가중치를 곱해 다음 날 키에 복사 + * + * @param sourceDate 원본 날짜 (전날) + * @param targetDate 대상 날짜 (다음 날) + * @param carryOverWeight 이월 가중치 (0.0 ~ 1.0, 권장: 0.1) + * @return 이월된 상품 수 + */ + public long carryOverScores(LocalDate sourceDate, LocalDate targetDate, double carryOverWeight) { + String sourceKey = cacheKeyGenerator.generateDailyRankingKey(sourceDate); + String targetKey = cacheKeyGenerator.generateDailyRankingKey(targetDate); + + try { + // 원본 키에서 모든 데이터 조회 + Set> sourceData = + redisTemplate.opsForZSet().rangeWithScores(sourceKey, 0, -1); + + if (sourceData == null || sourceData.isEmpty()) { + log.info("Carry-Over 스킵: 원본 랭킹 데이터 없음 - sourceKey={}", sourceKey); + return 0; + } + + // 가중치를 적용하여 대상 키에 추가 (기존 점수에 합산) + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + for (ZSetOperations.TypedTuple tuple : sourceData) { + String member = tuple.getValue(); + Double score = tuple.getScore(); + + if (member != null && score != null) { + double weightedScore = score * carryOverWeight; + zSetOps.incrementScore(targetKey, member, weightedScore); + } + } + + // TTL 설정 + redisTemplate.expire(targetKey, RANKING_TTL); + + long resultCount = sourceData.size(); + log.info("Score Carry-Over 완료: {} → {} (weight={}, count={})", + sourceKey, targetKey, carryOverWeight, resultCount); + + return resultCount; + + } catch (Exception e) { + log.error("Score Carry-Over 실패: {} → {}", sourceKey, targetKey, e); + throw new RuntimeException("Score Carry-Over 실패", e); + } + } +} diff --git a/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java new file mode 100644 index 000000000..48735fa19 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/dto/CachePayloads.java @@ -0,0 +1,85 @@ +package com.loopers.cache.dto; + +import java.math.BigDecimal; +import java.util.Objects; + +import lombok.Getter; + +/** + * + * @author hyunjikoh + * @since 2025. 12. 23. + */ +public class CachePayloads { + /** + * 랭킹 아이템 + */ + public record RankingItem( + long rank, + Long productId, + Double score + ) {} + + public record RankingScore( + Long productId, + EventType eventType, + double score, + long occurredAtEpochMillis + ) { + + @Getter + public enum EventType { + PRODUCT_VIEW(0.1), // 조회: Weight = 0.1, Score = 1 + LIKE_ACTION(0.2), // 좋아요: Weight = 0.2, Score = 1 + PAYMENT_SUCCESS(0.6); // 주문: Weight = 0.6, Score = price * amount (정규화 시에는 log 적용도 가능) + + private final double weight; + + EventType(double weight) { + this.weight = weight; + } + } + + /** + * 발생 시각으로부터 날짜 추출 + */ + public java.time.LocalDate getEventDate() { + return java.time.Instant.ofEpochMilli(occurredAtEpochMillis) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate(); + } + + /** + * 조회 이벤트 생성 + */ + public static RankingScore forProductView(Long productId, long occurredAt) { + return new RankingScore(productId, EventType.PRODUCT_VIEW, 1.0, occurredAt); + } + + /** + * 좋아요 이벤트 생성 + */ + public static RankingScore forLikeAction(Long productId, long occurredAt) { + return new RankingScore(productId, EventType.LIKE_ACTION, 1.0, occurredAt); + } + + /** + * 주문 이벤트 생성 메소드 (가격 * 수량 기반, 로그 정규화) + */ + public static RankingScore forPaymentSuccess(Long productId, BigDecimal totalPrice, long occurredAt) { + Objects.requireNonNull(totalPrice); + // 로그 정규화 적용하여 극값 방지 + // Math.log(x + 1)을 사용하여 0원일 때도 안전하게 처리 + double normalizedScore = Math.log(totalPrice.doubleValue() + 1); + return new RankingScore(productId, EventType.PAYMENT_SUCCESS, normalizedScore, occurredAt); + } + + /** + * 최종 가중 점수 계산 + */ + public double getWeightedScore() { + return eventType.getWeight() * score; + } + + } +}