diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 34ee692641..1e2cdb6cdd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -5,11 +5,18 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.ProductRankMonthly; +import com.loopers.domain.ranking.ProductRankWeekly; +import com.loopers.infrastructure.ranking.ProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.ranking.ProductRankWeeklyJpaRepository; import com.loopers.infrastructure.ranking.RankingCacheService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -19,12 +26,32 @@ @Component public class RankingFacade { + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final RankingCacheService rankingCacheService; private final ProductService productService; private final BrandService brandService; + private final ProductRankWeeklyJpaRepository productRankWeeklyJpaRepository; + private final ProductRankMonthlyJpaRepository productRankMonthlyJpaRepository; @Transactional(readOnly = true) - public List getRankings(String date, int page, int size) { + public List getRankings(String date, int page, int size, RankingType type) { + return switch (type) { + case DAILY -> getDailyRankings(date, page, size); + case WEEKLY -> getWeeklyRankings(date, page, size); + case MONTHLY -> getMonthlyRankings(date, page, size); + }; + } + + public long getTotalCount(String date, RankingType type) { + return switch (type) { + case DAILY -> rankingCacheService.getTotalCount(date); + case WEEKLY -> productRankWeeklyJpaRepository.countByYearWeek(toYearWeek(date)); + case MONTHLY -> productRankMonthlyJpaRepository.countByYearMonth(toYearMonth(date)); + }; + } + + private List getDailyRankings(String date, int page, int size) { int offset = (page - 1) * size; List productIdStrings = rankingCacheService.getProductIds(date, offset, size); @@ -34,11 +61,7 @@ public List getRankings(String date, int page, int size) { List ids = productIdStrings.stream().map(Long::valueOf).toList(); List products = productService.getProductsByIds(ids); - - List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); - Map brandMap = brandService.getBrandsByIds(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, b -> b)); - + Map brandMap = toBrandMap(products); Map productMap = products.stream() .collect(Collectors.toMap(Product::getId, p -> p)); @@ -46,20 +69,92 @@ public List getRankings(String date, int page, int size) { for (int i = 0; i < productIdStrings.size(); i++) { Long productId = Long.valueOf(productIdStrings.get(i)); Product product = productMap.get(productId); - if (product == null) { - continue; - } + if (product == null) continue; Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - continue; - } - ProductInfo productInfo = ProductInfo.from(product, brand); - result.add(RankingInfo.of(offset + i + 1, productInfo)); + if (brand == null) continue; + result.add(RankingInfo.of(offset + i + 1, ProductInfo.from(product, brand))); } return result; } - public long getTotalCount(String date) { - return rankingCacheService.getTotalCount(date); + private List getWeeklyRankings(String date, int page, int size) { + String yearWeek = toYearWeek(date); + List ranked = productRankWeeklyJpaRepository + .findByYearWeekOrderByRankPosition(yearWeek); + + int offset = (page - 1) * size; + List paged = ranked.stream() + .skip(offset).limit(size).toList(); + + if (paged.isEmpty()) { + return List.of(); + } + + List ids = paged.stream().map(ProductRankWeekly::getProductId).toList(); + List products = productService.getProductsByIds(ids); + Map brandMap = toBrandMap(products); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + return paged.stream() + .map(r -> { + Product product = productMap.get(r.getProductId()); + if (product == null) return null; + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) return null; + return RankingInfo.of(r.getRankPosition(), ProductInfo.from(product, brand)); + }) + .filter(r -> r != null) + .toList(); + } + + private List getMonthlyRankings(String date, int page, int size) { + String yearMonth = toYearMonth(date); + List ranked = productRankMonthlyJpaRepository + .findByYearMonthOrderByRankPosition(yearMonth); + + int offset = (page - 1) * size; + List paged = ranked.stream() + .skip(offset).limit(size).toList(); + + if (paged.isEmpty()) { + return List.of(); + } + + List ids = paged.stream().map(ProductRankMonthly::getProductId).toList(); + List products = productService.getProductsByIds(ids); + Map brandMap = toBrandMap(products); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + return paged.stream() + .map(r -> { + Product product = productMap.get(r.getProductId()); + if (product == null) return null; + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) return null; + return RankingInfo.of(r.getRankPosition(), ProductInfo.from(product, brand)); + }) + .filter(r -> r != null) + .toList(); + } + + private Map toBrandMap(List products) { + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + return brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + } + + private String toYearWeek(String date) { + LocalDate localDate = LocalDate.parse(date, DATE_FORMATTER); + WeekFields weekFields = WeekFields.ISO; + int week = localDate.get(weekFields.weekOfWeekBasedYear()); + int year = localDate.get(weekFields.weekBasedYear()); + return year + "-W" + String.format("%02d", week); + } + + private String toYearMonth(String date) { + LocalDate localDate = LocalDate.parse(date, DATE_FORMATTER); + return localDate.format(DateTimeFormatter.ofPattern("yyyy-MM")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingType.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingType.java new file mode 100644 index 0000000000..6b20591b21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingType.java @@ -0,0 +1,5 @@ +package com.loopers.application.ranking; + +public enum RankingType { + DAILY, WEEKLY, MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..aec51a56cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankMonthly; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductRankMonthlyJpaRepository extends JpaRepository { + + List findByYearMonthOrderByRankPosition(String yearMonth); + + long countByYearMonth(String yearMonth); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..19ff96d9e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankWeekly; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductRankWeeklyJpaRepository extends JpaRepository { + + List findByYearWeekOrderByRankPosition(String yearWeek); + + long countByYearWeek(String yearWeek); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index bfa70690f6..89d64a2953 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; +import com.loopers.application.ranking.RankingType; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -25,13 +26,15 @@ public class RankingV1Controller { @GetMapping public ApiResponse getRankings( @RequestParam(required = false) String date, + @RequestParam(defaultValue = "daily") String type, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { String rankingDate = (date != null) ? date : LocalDate.now().format(DATE_FORMATTER); + RankingType rankingType = RankingType.valueOf(type.toUpperCase()); - List rankings = rankingFacade.getRankings(rankingDate, page, size); - long totalElements = rankingFacade.getTotalCount(rankingDate); + List rankings = rankingFacade.getRankings(rankingDate, page, size, rankingType); + long totalElements = rankingFacade.getTotalCount(rankingDate, rankingType); List content = rankings.stream() .map(RankingV1Dto.RankingItemResponse::from) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java index 4234cabb7f..97f9fa43fe 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponFacadeTest.java @@ -1,10 +1,10 @@ package com.loopers.application.coupon; -import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponIssueRequest; +import com.loopers.domain.coupon.CouponService; import com.loopers.domain.coupon.CouponTemplateRepository; -import com.loopers.domain.coupon.CouponType; -import com.loopers.domain.coupon.IssuedCoupon; import com.loopers.domain.coupon.IssuedCouponRepository; +import com.loopers.domain.outbox.OutboxEventPublisher; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; @@ -17,9 +17,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.ZonedDateTime; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -29,106 +26,90 @@ class CouponFacadeTest { @Mock - private CouponTemplateRepository couponTemplateRepository; + private CouponService couponService; @Mock - private IssuedCouponRepository issuedCouponRepository; + private UserService userService; @Mock - private UserService userService; + private OutboxEventPublisher outboxEventPublisher; - private CouponFacade couponFacade; + @Mock + private CouponTemplateRepository couponTemplateRepository; + + @Mock + private IssuedCouponRepository issuedCouponRepository; - private static final ZonedDateTime FUTURE = ZonedDateTime.now().plusDays(30); + private CouponFacade couponFacade; @BeforeEach void setUp() { - couponFacade = new CouponFacade(couponTemplateRepository, issuedCouponRepository, userService); + couponFacade = new CouponFacade(couponService, userService, outboxEventPublisher, + couponTemplateRepository, issuedCouponRepository); } - @DisplayName("쿠폰 발급") + @DisplayName("쿠폰 발급 요청") @Nested - class IssueCoupon { + class RequestIssue { - @DisplayName("유효한 쿠폰 템플릿으로 발급 요청하면, 발급된 쿠폰을 반환한다.") + @DisplayName("유효한 쿠폰으로 발급 요청하면, 발급 요청 정보를 반환한다.") @Test - void returnsIssuedCoupon_whenTemplateIsValid() { + void returnsCouponIssueInfo_whenCouponIsValid() { // Arrange String loginId = "user1"; String rawPassword = "Test1234!"; User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); - CouponTemplate template = new CouponTemplate("10% 할인", CouponType.RATE, 10, null, FUTURE); + CouponIssueRequest request = new CouponIssueRequest(1L, user.getId()); given(userService.authenticate(loginId, rawPassword)).willReturn(user); - given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); - given(issuedCouponRepository.existsByUserIdAndCouponTemplateId(any(), any())).willReturn(false); - given(issuedCouponRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(couponService.createRequest(1L, user.getId())).willReturn(request); // Act - CouponInfo.IssuedInfo result = couponFacade.issueCoupon(loginId, rawPassword, 1L); + CouponIssueInfo result = couponFacade.requestIssue(loginId, rawPassword, 1L); // Assert assertThat(result).isNotNull(); } - @DisplayName("존재하지 않는 쿠폰 템플릿으로 발급 요청하면, NOT_FOUND 예외가 발생한다.") + @DisplayName("존재하지 않는 쿠폰으로 발급 요청하면, NOT_FOUND 예외가 발생한다.") @Test - void throwsNotFound_whenTemplateDoesNotExist() { + void throwsNotFound_whenCouponDoesNotExist() { // Arrange String loginId = "user1"; String rawPassword = "Test1234!"; User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); given(userService.authenticate(loginId, rawPassword)).willReturn(user); - given(couponTemplateRepository.findById(999L)).willReturn(Optional.empty()); + given(couponService.createRequest(any(), any())) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "쿠폰을 찾을 수 없습니다.")); // Act CoreException exception = assertThrows(CoreException.class, - () -> couponFacade.issueCoupon(loginId, rawPassword, 999L)); + () -> couponFacade.requestIssue(loginId, rawPassword, 999L)); // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - - @DisplayName("이미 발급받은 쿠폰을 다시 발급 요청하면, CONFLICT 예외가 발생한다.") - @Test - void throwsConflict_whenCouponAlreadyIssued() { - // Arrange - String loginId = "user1"; - String rawPassword = "Test1234!"; - User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); - CouponTemplate template = new CouponTemplate("10% 할인", CouponType.RATE, 10, null, FUTURE); - - given(userService.authenticate(loginId, rawPassword)).willReturn(user); - given(couponTemplateRepository.findById(1L)).willReturn(Optional.of(template)); - given(issuedCouponRepository.existsByUserIdAndCouponTemplateId(any(), any())).willReturn(true); - - // Act - CoreException exception = assertThrows(CoreException.class, - () -> couponFacade.issueCoupon(loginId, rawPassword, 1L)); - - // Assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); - } } - @DisplayName("내 쿠폰 목록 조회") + @DisplayName("쿠폰 발급 결과 조회") @Nested - class GetMyCoupons { + class GetIssueResult { - @DisplayName("인증된 유저의 쿠폰 목록을 반환한다.") + @DisplayName("인증된 유저가 발급 요청 결과를 조회하면 결과를 반환한다.") @Test - void returnsMyCoupons_whenAuthenticated() { + void returnsIssueResult_whenAuthenticated() { // Arrange String loginId = "user1"; String rawPassword = "Test1234!"; User user = new User(loginId, "encoded", "홍길동", "19900101", "test@naver.com"); + CouponIssueRequest request = new CouponIssueRequest(1L, user.getId()); given(userService.authenticate(loginId, rawPassword)).willReturn(user); - given(issuedCouponRepository.findAllByUserId(any())).willReturn(java.util.List.of()); + given(couponService.getRequest(1L)).willReturn(request); // Act - var result = couponFacade.getMyCoupons(loginId, rawPassword); + CouponIssueInfo result = couponFacade.getIssueResult(loginId, rawPassword, 1L); // Assert assertThat(result).isNotNull(); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index 20e86f10b8..3b75760f96 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -11,6 +11,7 @@ import com.loopers.infrastructure.coupon.CouponTemplateJpaRepository; import com.loopers.infrastructure.coupon.IssuedCouponJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.queue.QueueCacheService; import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -53,6 +54,9 @@ class OrderConcurrencyTest { @Autowired private IssuedCouponJpaRepository issuedCouponJpaRepository; + @Autowired + private QueueCacheService queueCacheService; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -90,6 +94,7 @@ void onlyOneOrderSucceeds_whenTwoThreadsUseSameCouponConcurrently() throws Inter IssuedCoupon issuedCoupon = issuedCouponJpaRepository.save( new IssuedCoupon(savedUser.getId(), template.getId()) ); + String entryToken = queueCacheService.issueToken(savedUser.getId()); int threadCount = 2; ExecutorService executor = Executors.newFixedThreadPool(threadCount); @@ -104,7 +109,7 @@ void onlyOneOrderSucceeds_whenTwoThreadsUseSameCouponConcurrently() throws Inter try { latch.await(); orderFacade.createOrder( - "couponuser", RAW_PASSWORD, + "couponuser", RAW_PASSWORD, entryToken, List.of(new OrderRequest.OrderItemRequest(product.getId(), 1)), issuedCoupon.getId() ); @@ -142,12 +147,14 @@ void onlyFiveOrdersSucceed_whenTenUsersOrderProductWithStockOfFiveConcurrently() int threadCount = 10; String encodedPassword = passwordEncoder.encode(RAW_PASSWORD); List loginIds = new ArrayList<>(); + List entryTokens = new ArrayList<>(); for (int i = 0; i < threadCount; i++) { String loginId = "stockuser" + i; User user = new User(loginId, encodedPassword, "재고유저" + i, "19900101", "stock" + i + "@test.com"); user.restoreBalance(100_000L); - userJpaRepository.save(user); + User savedUser = userJpaRepository.save(user); loginIds.add(loginId); + entryTokens.add(queueCacheService.issueToken(savedUser.getId())); } ExecutorService executor = Executors.newFixedThreadPool(threadCount); @@ -158,12 +165,13 @@ void onlyFiveOrdersSucceed_whenTenUsersOrderProductWithStockOfFiveConcurrently() // Act for (int i = 0; i < threadCount; i++) { final String loginId = loginIds.get(i); + final String entryToken = entryTokens.get(i); executor.submit(() -> { latch.countDown(); try { latch.await(); orderFacade.createOrder( - loginId, RAW_PASSWORD, + loginId, RAW_PASSWORD, entryToken, List.of(new OrderRequest.OrderItemRequest(product.getId(), 1)), null ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index bea955ef48..5470102069 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -13,6 +13,7 @@ import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import com.loopers.infrastructure.preorder.PreOrderCacheService; +import com.loopers.infrastructure.queue.QueueCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -53,11 +54,25 @@ class OrderFacadeTest { @Mock private OutboxEventPublisher outboxEventPublisher; + @Mock + private QueueCacheService queueCacheService; + + @Mock + private IssuedCouponRepository issuedCouponRepository; + + @Mock + private CouponTemplateRepository couponTemplateRepository; + + @Mock + private PreOrderCacheService preOrderCacheService; + private OrderFacade orderFacade; @BeforeEach void setUp() { - orderFacade = new OrderFacade(orderService, productService, userService, brandService, eventPublisher, outboxEventPublisher); + orderFacade = new OrderFacade(orderService, productService, userService, brandService, + eventPublisher, outboxEventPublisher, queueCacheService, + issuedCouponRepository, couponTemplateRepository, preOrderCacheService); } @DisplayName("주문 생성") @@ -78,6 +93,7 @@ void publishesOrderCreatedEvent_whenOrderCreated() { List items = List.of(new OrderRequest.OrderItemRequest(1L, 1)); given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(queueCacheService.validateToken(any(), any())).willReturn(true); given(productService.getProduct(1L)).willReturn(product); given(brandService.getBrandsByIds(any())).willReturn(List.of(brand)); given(orderService.generateOrderNumber()).willReturn("ORD-20240101-TEST"); @@ -86,7 +102,7 @@ void publishesOrderCreatedEvent_whenOrderCreated() { given(orderService.getOrderItems(any())).willReturn(List.of()); // Act - orderFacade.createOrder(loginId, rawPassword, items); + orderFacade.createOrder(loginId, rawPassword, "MOCK_TOKEN", items, null); // Assert verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index cc1365ffb5..6e8c47971f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -4,6 +4,10 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.domain.ranking.ProductRankMonthly; +import com.loopers.domain.ranking.ProductRankWeekly; +import com.loopers.infrastructure.ranking.ProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.ranking.ProductRankWeeklyJpaRepository; import com.loopers.infrastructure.ranking.RankingCacheService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -30,11 +34,18 @@ class RankingFacadeTest { @Mock private BrandService brandService; + @Mock + private ProductRankWeeklyJpaRepository productRankWeeklyJpaRepository; + + @Mock + private ProductRankMonthlyJpaRepository productRankMonthlyJpaRepository; + private RankingFacade rankingFacade; @BeforeEach void setUp() { - rankingFacade = new RankingFacade(rankingCacheService, productService, brandService); + rankingFacade = new RankingFacade(rankingCacheService, productService, brandService, + productRankWeeklyJpaRepository, productRankMonthlyJpaRepository); } @DisplayName("랭킹 목록 조회") @@ -48,55 +59,127 @@ void returnsEmpty_whenNoRankingData() { given(rankingCacheService.getProductIds("20260408", 0, 20)).willReturn(List.of()); // Act - List result = rankingFacade.getRankings("20260408", 1, 20); + List result = rankingFacade.getRankings("20260408", 1, 20, RankingType.DAILY); // Assert assertThat(result).isEmpty(); } - @DisplayName("ZSET 순서대로 rank가 1부터 부여된다.") + @DisplayName("ZSET 첫 번째 항목의 rank는 1이다.") @Test void assignsRankInOrder_whenRankingDataExists() { // Arrange - Product productA = new Product(1L, "상품A", 10000, 100, "설명A", "https://img.a"); - Product productB = new Product(2L, "상품B", 5000, 50, "설명B", "https://img.b"); + // BaseEntity.id는 항상 0L(final)이므로, ZSET id도 "0"으로 맞춰 productMap 조회가 가능하게 한다. + Product product = new Product(0L, "상품A", 10000, 100, "설명A", "https://img.a"); Brand brand = new Brand("브랜드X", "브랜드 설명"); given(rankingCacheService.getProductIds("20260408", 0, 20)) - .willReturn(List.of("1", "2")); // 1번이 더 높은 점수 - given(productService.getProductsByIds(List.of(1L, 2L))) - .willReturn(List.of(productA, productB)); - given(brandService.getBrandsByIds(List.of(1L))).willReturn(List.of(brand)); + .willReturn(List.of("0")); + given(productService.getProductsByIds(List.of(0L))) + .willReturn(List.of(product)); + given(brandService.getBrandsByIds(List.of(0L))).willReturn(List.of(brand)); // Act - List result = rankingFacade.getRankings("20260408", 1, 20); + List result = rankingFacade.getRankings("20260408", 1, 20, RankingType.DAILY); // Assert - assertThat(result).hasSize(2); + assertThat(result).hasSize(1); assertThat(result.get(0).rank()).isEqualTo(1L); - assertThat(result.get(1).rank()).isEqualTo(2L); - assertThat(result.get(0).productId()).isEqualTo(1L); - assertThat(result.get(1).productId()).isEqualTo(2L); } @DisplayName("page=2 요청 시 rank가 size+1부터 시작된다.") @Test void assignsRankFromOffset_whenPage2Requested() { // Arrange - Product productC = new Product(3L, "상품C", 3000, 30, "설명C", "https://img.c"); + Product product = new Product(0L, "상품C", 3000, 30, "설명C", "https://img.c"); Brand brand = new Brand("브랜드X", "브랜드 설명"); given(rankingCacheService.getProductIds("20260408", 20, 20)) - .willReturn(List.of("3")); - given(productService.getProductsByIds(List.of(3L))).willReturn(List.of(productC)); - given(brandService.getBrandsByIds(List.of(1L))).willReturn(List.of(brand)); + .willReturn(List.of("0")); + given(productService.getProductsByIds(List.of(0L))).willReturn(List.of(product)); + given(brandService.getBrandsByIds(List.of(0L))).willReturn(List.of(brand)); // Act - List result = rankingFacade.getRankings("20260408", 2, 20); + List result = rankingFacade.getRankings("20260408", 2, 20, RankingType.DAILY); // Assert assertThat(result.get(0).rank()).isEqualTo(21L); } + + @DisplayName("주간 랭킹 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoWeeklyRankingData() { + // Arrange + given(productRankWeeklyJpaRepository.findByYearWeekOrderByRankPosition("2026-W16")) + .willReturn(List.of()); + + // Act + List result = rankingFacade.getRankings("20260416", 1, 20, RankingType.WEEKLY); + + // Assert + assertThat(result).isEmpty(); + } + + @DisplayName("주간 랭킹 데이터가 있으면 rank_position 순서대로 반환한다.") + @Test + void returnsWeeklyRankings_inRankPositionOrder() { + // Arrange + // BaseEntity의 id는 항상 0L이므로, productId도 0L로 맞춰 productMap 조회가 가능하게 한다. + ProductRankWeekly rank1 = ProductRankWeekly.of(0L, 90.0, 10L, 5L, 20L, "2026-W16"); + rank1.assignRank(1); + Product product = new Product(0L, "상품A", 10000, 100, "설명A", "https://img.a"); + Brand brand = new Brand("브랜드X", "브랜드 설명"); + + given(productRankWeeklyJpaRepository.findByYearWeekOrderByRankPosition("2026-W16")) + .willReturn(List.of(rank1)); + given(productService.getProductsByIds(List.of(0L))).willReturn(List.of(product)); + given(brandService.getBrandsByIds(List.of(0L))).willReturn(List.of(brand)); + + // Act + List result = rankingFacade.getRankings("20260416", 1, 20, RankingType.WEEKLY); + + // Assert + assertThat(result).hasSize(1); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).name()).isEqualTo("상품A"); + } + + @DisplayName("월간 랭킹 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoMonthlyRankingData() { + // Arrange + given(productRankMonthlyJpaRepository.findByYearMonthOrderByRankPosition("2026-04")) + .willReturn(List.of()); + + // Act + List result = rankingFacade.getRankings("20260416", 1, 20, RankingType.MONTHLY); + + // Assert + assertThat(result).isEmpty(); + } + + @DisplayName("월간 랭킹 데이터가 있으면 rank_position 순서대로 반환한다.") + @Test + void returnsMonthlyRankings_inRankPositionOrder() { + // Arrange + ProductRankMonthly rank1 = ProductRankMonthly.of(0L, 80.0, 8L, 4L, 15L, "2026-04"); + rank1.assignRank(1); + Product product = new Product(0L, "상품B", 5000, 50, "설명B", "https://img.b"); + Brand brand = new Brand("브랜드Y", "브랜드 설명"); + + given(productRankMonthlyJpaRepository.findByYearMonthOrderByRankPosition("2026-04")) + .willReturn(List.of(rank1)); + given(productService.getProductsByIds(List.of(0L))).willReturn(List.of(product)); + given(brandService.getBrandsByIds(List.of(0L))).willReturn(List.of(brand)); + + // Act + List result = rankingFacade.getRankings("20260416", 1, 20, RankingType.MONTHLY); + + // Assert + assertThat(result).hasSize(1); + assertThat(result.get(0).rank()).isEqualTo(1L); + assertThat(result.get(0).name()).isEqualTo("상품B"); + } } @DisplayName("랭킹 전체 개수 조회") @@ -110,10 +193,36 @@ void returnsTotalCount() { given(rankingCacheService.getTotalCount("20260408")).willReturn(3L); // Act - long count = rankingFacade.getTotalCount("20260408"); + long count = rankingFacade.getTotalCount("20260408", RankingType.DAILY); // Assert assertThat(count).isEqualTo(3L); } + + @DisplayName("주간 랭킹 전체 개수를 반환한다.") + @Test + void returnsWeeklyTotalCount() { + // Arrange + given(productRankWeeklyJpaRepository.countByYearWeek("2026-W16")).willReturn(50L); + + // Act + long count = rankingFacade.getTotalCount("20260416", RankingType.WEEKLY); + + // Assert + assertThat(count).isEqualTo(50L); + } + + @DisplayName("월간 랭킹 전체 개수를 반환한다.") + @Test + void returnsMonthlyTotalCount() { + // Arrange + given(productRankMonthlyJpaRepository.countByYearMonth("2026-04")).willReturn(100L); + + // Act + long count = rankingFacade.getTotalCount("20260416", RankingType.MONTHLY); + + // Assert + assertThat(count).isEqualTo(100L); + } } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..23b91a7399 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,107 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.MonthlyRankingClearTasklet; +import com.loopers.batch.job.ranking.step.MonthlyRankingItemProcessor; +import com.loopers.batch.job.ranking.step.MonthlyRankingRankTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankMonthly; +import com.loopers.infrastructure.ranking.ProductRankMonthlyJpaRepository; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.data.RepositoryItemWriter; +import org.springframework.batch.item.data.builder.RepositoryItemWriterBuilder; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_CLEAR_NAME = "monthlyRankingClearStep"; + private static final String STEP_AGGREGATE_NAME = "monthlyRankingAggregateStep"; + private static final String STEP_RANK_NAME = "monthlyRankingRankStep"; + private static final int CHUNK_SIZE = 1000; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + + private final MonthlyRankingClearTasklet monthlyRankingClearTasklet; + private final MonthlyRankingItemProcessor monthlyRankingItemProcessor; + private final MonthlyRankingRankTasklet monthlyRankingRankTasklet; + private final ProductRankMonthlyJpaRepository productRankMonthlyJpaRepository; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingClearStep()) + .next(monthlyRankingAggregateStep()) + .next(monthlyRankingRankStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEAR_NAME) + public Step monthlyRankingClearStep() { + return new StepBuilder(STEP_CLEAR_NAME, jobRepository) + .tasklet(monthlyRankingClearTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE_NAME) + public Step monthlyRankingAggregateStep() { + return new StepBuilder(STEP_AGGREGATE_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsReader()) + .processor(monthlyRankingItemProcessor) + .writer(productRankMonthlyWriter()) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_RANK_NAME) + public Step monthlyRankingRankStep() { + return new StepBuilder(STEP_RANK_NAME, jobRepository) + .tasklet(monthlyRankingRankTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + private JpaPagingItemReader productMetricsReader() { + return new JpaPagingItemReaderBuilder() + .name("monthlyProductMetricsReader") + .entityManagerFactory(entityManagerFactory) + .queryString("SELECT p FROM ProductMetrics p") + .pageSize(CHUNK_SIZE) + .build(); + } + + private RepositoryItemWriter productRankMonthlyWriter() { + return new RepositoryItemWriterBuilder() + .repository(productRankMonthlyJpaRepository) + .methodName("save") + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..61d1900f1e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,107 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.WeeklyRankingClearTasklet; +import com.loopers.batch.job.ranking.step.WeeklyRankingItemProcessor; +import com.loopers.batch.job.ranking.step.WeeklyRankingRankTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankWeekly; +import com.loopers.infrastructure.ranking.ProductRankWeeklyJpaRepository; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.data.RepositoryItemWriter; +import org.springframework.batch.item.data.builder.RepositoryItemWriterBuilder; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_CLEAR_NAME = "weeklyRankingClearStep"; + private static final String STEP_AGGREGATE_NAME = "weeklyRankingAggregateStep"; + private static final String STEP_RANK_NAME = "weeklyRankingRankStep"; + private static final int CHUNK_SIZE = 1000; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + + private final WeeklyRankingClearTasklet weeklyRankingClearTasklet; + private final WeeklyRankingItemProcessor weeklyRankingItemProcessor; + private final WeeklyRankingRankTasklet weeklyRankingRankTasklet; + private final ProductRankWeeklyJpaRepository productRankWeeklyJpaRepository; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingClearStep()) + .next(weeklyRankingAggregateStep()) + .next(weeklyRankingRankStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEAR_NAME) + public Step weeklyRankingClearStep() { + return new StepBuilder(STEP_CLEAR_NAME, jobRepository) + .tasklet(weeklyRankingClearTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE_NAME) + public Step weeklyRankingAggregateStep() { + return new StepBuilder(STEP_AGGREGATE_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productMetricsReader()) + .processor(weeklyRankingItemProcessor) + .writer(productRankWeeklyWriter()) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_RANK_NAME) + public Step weeklyRankingRankStep() { + return new StepBuilder(STEP_RANK_NAME, jobRepository) + .tasklet(weeklyRankingRankTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + private JpaPagingItemReader productMetricsReader() { + return new JpaPagingItemReaderBuilder() + .name("productMetricsReader") + .entityManagerFactory(entityManagerFactory) + .queryString("SELECT p FROM ProductMetrics p") + .pageSize(CHUNK_SIZE) + .build(); + } + + private RepositoryItemWriter productRankWeeklyWriter() { + return new RepositoryItemWriterBuilder() + .repository(productRankWeeklyJpaRepository) + .methodName("save") + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingClearTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingClearTasklet.java new file mode 100644 index 0000000000..0746fcaf47 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingClearTasklet.java @@ -0,0 +1,45 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class MonthlyRankingClearTasklet implements Tasklet { + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearMonth = toYearMonth(targetDate); + log.info("[MonthlyRankingClearTasklet] 기존 데이터 삭제: yearMonth={}", yearMonth); + + int deleted = jdbcTemplate.update( + "DELETE FROM mv_product_rank_monthly WHERE period_month = ?", + yearMonth + ); + log.info("[MonthlyRankingClearTasklet] 삭제 완료: {}건", deleted); + return RepeatStatus.FINISHED; + } + + public static String toYearMonth(String targetDate) { + LocalDate date = LocalDate.parse(targetDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + return date.format(DateTimeFormatter.ofPattern("yyyy-MM")); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingItemProcessor.java new file mode 100644 index 0000000000..b7de042b3d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingItemProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankMonthly; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@StepScope +@Component +public class MonthlyRankingItemProcessor implements ItemProcessor { + + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.7; + private static final double WEIGHT_VIEW = 0.1; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public ProductRankMonthly process(ProductMetrics item) { + String yearMonth = MonthlyRankingClearTasklet.toYearMonth(targetDate); + double score = item.getLikeCount() * WEIGHT_LIKE + + item.getOrderCount() * WEIGHT_ORDER + + item.getViewCount() * WEIGHT_VIEW; + + return ProductRankMonthly.of( + item.getProductId(), + score, + item.getLikeCount(), + item.getOrderCount(), + item.getViewCount(), + yearMonth + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingRankTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingRankTasklet.java new file mode 100644 index 0000000000..f65fd5202c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingRankTasklet.java @@ -0,0 +1,53 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class MonthlyRankingRankTasklet implements Tasklet { + + private static final int TOP_N = 100; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearMonth = MonthlyRankingClearTasklet.toYearMonth(targetDate); + log.info("[MonthlyRankingRankTasklet] 랭킹 순위 부여 시작: yearMonth={}", yearMonth); + + jdbcTemplate.update( + """ + UPDATE mv_product_rank_monthly m + JOIN ( + SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn + FROM mv_product_rank_monthly + WHERE period_month = ? + ) ranked ON m.id = ranked.id + SET m.rank_position = ranked.rn + WHERE m.period_month = ? + """, + yearMonth, yearMonth + ); + + int deleted = jdbcTemplate.update( + "DELETE FROM mv_product_rank_monthly WHERE period_month = ? AND rank_position > ?", + yearMonth, TOP_N + ); + log.info("[MonthlyRankingRankTasklet] TOP {} 초과 삭제: {}건", TOP_N, deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingClearTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingClearTasklet.java new file mode 100644 index 0000000000..6bbde6618b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingClearTasklet.java @@ -0,0 +1,51 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class WeeklyRankingClearTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearWeek = toYearWeek(targetDate); + log.info("[WeeklyRankingClearTasklet] 기존 데이터 삭제: yearWeek={}", yearWeek); + + int deleted = jdbcTemplate.update( + "DELETE FROM mv_product_rank_weekly WHERE period_week = ?", + yearWeek + ); + log.info("[WeeklyRankingClearTasklet] 삭제 완료: {}건", deleted); + return RepeatStatus.FINISHED; + } + + public static String toYearWeek(String targetDate) { + LocalDate date = LocalDate.parse(targetDate, DateTimeFormatter.ofPattern("yyyyMMdd")); + WeekFields weekFields = WeekFields.ISO; + int week = date.get(weekFields.weekOfWeekBasedYear()); + int year = date.get(weekFields.weekBasedYear()); + return year + "-W" + String.format("%02d", week); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingItemProcessor.java new file mode 100644 index 0000000000..cb936be924 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingItemProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankWeekly; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@StepScope +@Component +public class WeeklyRankingItemProcessor implements ItemProcessor { + + private static final double WEIGHT_LIKE = 0.2; + private static final double WEIGHT_ORDER = 0.7; + private static final double WEIGHT_VIEW = 0.1; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + @Override + public ProductRankWeekly process(ProductMetrics item) { + String yearWeek = WeeklyRankingClearTasklet.toYearWeek(targetDate); + double score = item.getLikeCount() * WEIGHT_LIKE + + item.getOrderCount() * WEIGHT_ORDER + + item.getViewCount() * WEIGHT_VIEW; + + return ProductRankWeekly.of( + item.getProductId(), + score, + item.getLikeCount(), + item.getOrderCount(), + item.getViewCount(), + yearWeek + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingRankTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingRankTasklet.java new file mode 100644 index 0000000000..d530afdad9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingRankTasklet.java @@ -0,0 +1,53 @@ +package com.loopers.batch.job.ranking.step; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@StepScope +@Component +@RequiredArgsConstructor +public class WeeklyRankingRankTasklet implements Tasklet { + + private static final int TOP_N = 100; + + @Value("#{jobParameters['targetDate']}") + private String targetDate; + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearWeek = WeeklyRankingClearTasklet.toYearWeek(targetDate); + log.info("[WeeklyRankingRankTasklet] 랭킹 순위 부여 시작: yearWeek={}", yearWeek); + + jdbcTemplate.update( + """ + UPDATE mv_product_rank_weekly w + JOIN ( + SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC) AS rn + FROM mv_product_rank_weekly + WHERE period_week = ? + ) ranked ON w.id = ranked.id + SET w.rank_position = ranked.rn + WHERE w.period_week = ? + """, + yearWeek, yearWeek + ); + + int deleted = jdbcTemplate.update( + "DELETE FROM mv_product_rank_weekly WHERE period_week = ? AND rank_position > ?", + yearWeek, TOP_N + ); + log.info("[WeeklyRankingRankTasklet] TOP {} 초과 삭제: {}건", TOP_N, deleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 0000000000..d7ba696618 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,33 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Entity +@Table(name = "product_metrics") +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "order_count", nullable = false) + private Long orderCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected ProductMetrics() {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 0000000000..f691f0e6e8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..d8184aecb3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankMonthly; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRankMonthlyJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..bf44aedede --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankWeekly; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRankWeeklyJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..a2ece3b2f0 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,68 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.infrastructure.ranking.ProductRankMonthlyJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductRankMonthlyJpaRepository productRankMonthlyJpaRepository; + + @DisplayName("targetDate 파라미터 없이 실행하면 monthlyRankingJob이 실패한다.") + @Test + void fails_whenTargetDateParamIsMissing() throws Exception { + // Arrange + jobLauncherTestUtils.setJob(job); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // Assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("targetDate 파라미터와 함께 실행하면 monthlyRankingJob이 성공한다.") + @Test + void success_whenTargetDateParamIsGiven() throws Exception { + // Arrange + jobLauncherTestUtils.setJob(job); + var jobParameters = new JobParametersBuilder() + .addString("targetDate", "20260416") + .toJobParameters(); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // Assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..129729e54b --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,68 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.infrastructure.ranking.ProductRankWeeklyJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductRankWeeklyJpaRepository productRankWeeklyJpaRepository; + + @DisplayName("targetDate 파라미터 없이 실행하면 weeklyRankingJob이 실패한다.") + @Test + void fails_whenTargetDateParamIsMissing() throws Exception { + // Arrange + jobLauncherTestUtils.setJob(job); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // Assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("targetDate 파라미터와 함께 실행하면 weeklyRankingJob이 성공한다.") + @Test + void success_whenTargetDateParamIsGiven() throws Exception { + // Arrange + jobLauncherTestUtils.setJob(job); + var jobParameters = new JobParametersBuilder() + .addString("targetDate", "20260416") + .toJobParameters(); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // Assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/k6/06_ranking.js b/k6/06_ranking.js index 0c97f48fe2..7d97b071d8 100644 --- a/k6/06_ranking.js +++ b/k6/06_ranking.js @@ -1,124 +1,323 @@ /** - * 랭킹 API 검증 테스트 + * 랭킹 시스템 부하 테스트 * - * 목표: - * 1. 랭킹 목록 조회 API 정상 동작 확인 (상품 정보 Aggregation 포함) - * 2. 이전 날짜 랭킹 조회 정상 동작 확인 - * 3. 상품 상세 조회 시 랭킹 순위 반환 확인 + * 시나리오: + * A. 랭킹 Top-N 읽기 — GET /api/v1/rankings (Redis ZREVRANGE) + * B. 상품 상세 + rank 읽기 — GET /api/v1/products/{id} (ZREVRANK 오버헤드 측정) + * C. 이벤트 쓰기 파이프라인 — GET(조회) + POST(좋아요) → Kafka → ZSET 경로 + * D. Hot Product 경합 — 상위 3개 상품에 70% 트래픽 집중 * * 사전 조건: - * - 서버 실행 중 - * - Redis에 랭킹 데이터가 적재되어 있어야 함 (상품 조회/주문 이벤트 발생 후) + * - 서버 실행 중 (commerce-api, commerce-streamer, Redis, Kafka) + * - user1~user100 계정 등록 완료 (bash scripts/seed-users.sh) + * - 상품 데이터 존재 (productId 1~20) * * 실행: k6 run k6/06_ranking.js */ import http from 'k6/http'; import { check, sleep } from 'k6'; -import { Counter, Rate } from 'k6/metrics'; +import { Rate, Trend } from 'k6/metrics'; const BASE_URL = 'http://localhost:8080'; -// 오늘 날짜 계산 (yyyyMMdd) +// ── 공통 헬퍼 ────────────────────────────────────────────────────────────── + function todayDate() { const now = new Date(); - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, '0'); - const d = String(now.getDate()).padStart(2, '0'); - return `${y}${m}${d}`; + return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; } -// 어제 날짜 계산 function yesterdayDate() { - const now = new Date(); - now.setDate(now.getDate() - 1); - const y = now.getFullYear(); - const m = String(now.getMonth() + 1).padStart(2, '0'); - const d = String(now.getDate()).padStart(2, '0'); - return `${y}${m}${d}`; + const d = new Date(); + d.setDate(d.getDate() - 1); + return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`; +} + +function userHeaders(vuId) { + const idx = (vuId % 100) + 1; + return { + 'X-Loopers-LoginId': `user${idx}`, + 'X-Loopers-LoginPw': 'Test1234!', + }; } -const rankingSuccess = new Counter('ranking_success'); -const rankingFail = new Counter('ranking_fail'); -const productDetailSuccess = new Counter('product_detail_success'); -const rankFieldPresent = new Rate('rank_field_present'); +// Hot Product: 상위 3개에 70% 집중, 나머지 30%는 4~20에 분산 +function pickProductId(vuId, iter) { + const r = Math.random(); + if (r < 0.70) { + return (((vuId + iter) % 3) + 1); // 1, 2, 3 + } + return (Math.floor(Math.random() * 17) + 4); // 4~20 +} + +// ── 커스텀 메트릭 ────────────────────────────────────────────────────────── + +const rankingReadFailRate = new Rate('ranking_read_fail'); +const productDetailFailRate = new Rate('product_detail_fail'); +const writeEventFailRate = new Rate('write_event_fail'); +const hotProductFailRate = new Rate('hot_product_fail'); +const weeklyRankingFailRate = new Rate('weekly_ranking_fail'); +const monthlyRankingFailRate = new Rate('monthly_ranking_fail'); +const zrevrankLatency = new Trend('zrevrank_extra_latency_ms'); + +// ── 시나리오 옵션 ────────────────────────────────────────────────────────── export const options = { scenarios: { - ranking_check: { - executor: 'shared-iterations', - vus: 5, - iterations: 20, - maxDuration: '30s', + // A. 랭킹 Top-N 읽기 — 읽기 전용, Redis ZREVRANGE가 대부분 처리 + ranking_read: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '15s', target: 100 }, + { duration: '30s', target: 300 }, + { duration: '15s', target: 0 }, + ], + exec: 'scenarioRankingRead', + tags: { scenario: 'A-ranking-read' }, + }, + + // B. 상품 상세 + rank — ZREVRANK 추가 오버헤드 측정 + product_detail_with_rank: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '15s', target: 50 }, + { duration: '30s', target: 200 }, + { duration: '15s', target: 0 }, + ], + exec: 'scenarioProductDetail', + tags: { scenario: 'B-product-detail' }, + startTime: '65s', // A 종료 후 시작 + }, + + // C. 이벤트 쓰기 파이프라인 — 조회 + 좋아요 → Kafka → ZSET + write_pipeline: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 100 }, + { duration: '40s', target: 500 }, + { duration: '10s', target: 0 }, + ], + exec: 'scenarioWritePipeline', + tags: { scenario: 'C-write-pipeline' }, + startTime: '130s', // B 종료 후 시작 + }, + + // D. Hot Product 경합 — 상위 3개 상품에 70% 트래픽 집중 + hot_product: { + executor: 'constant-vus', + vus: 200, + duration: '30s', + exec: 'scenarioHotProduct', + tags: { scenario: 'D-hot-product' }, + startTime: '200s', // C 종료 후 시작 + }, + + // E. 주간 랭킹 조회 — MV 테이블 기반 읽기 성능 측정 + weekly_ranking_read: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 50 }, + { duration: '20s', target: 150 }, + { duration: '10s', target: 0 }, + ], + exec: 'scenarioWeeklyRankingRead', + tags: { scenario: 'E-weekly-ranking' }, + startTime: '240s', // D 종료 후 시작 + }, + + // F. 월간 랭킹 조회 — MV 테이블 기반 읽기 성능 측정 + monthly_ranking_read: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 50 }, + { duration: '20s', target: 150 }, + { duration: '10s', target: 0 }, + ], + exec: 'scenarioMonthlyRankingRead', + tags: { scenario: 'F-monthly-ranking' }, + startTime: '285s', // E 종료 후 시작 }, }, + thresholds: { - http_req_failed: ['rate<0.05'], - http_req_duration: ['p(95)<500'], + // A: Redis 읽기이므로 넉넉하게 + 'http_req_duration{scenario:A-ranking-read}': ['p(95)<150', 'p(99)<300'], + // B: ZREVRANK 1회 추가, 상품 DB 조회 포함 + 'http_req_duration{scenario:B-product-detail}': ['p(95)<200', 'p(99)<500'], + // C: Kafka 비동기 경로이므로 쓰기 자체(API 응답)는 빠를 수 있음 + 'http_req_duration{scenario:C-write-pipeline}': ['p(95)<500', 'p(99)<1000'], + // D: Hot Key 경합 — 동일 상품 ZINCRBY 병목 구간 측정 + 'http_req_duration{scenario:D-hot-product}': ['p(95)<300', 'p(99)<600'], + // E: MV 테이블 조회 — DB 읽기이므로 Redis보다 여유있게 + 'http_req_duration{scenario:E-weekly-ranking}': ['p(95)<300', 'p(99)<600'], + // F: MV 테이블 조회 — 월간 집계는 데이터량이 많을 수 있으므로 허용치 넉넉하게 + 'http_req_duration{scenario:F-monthly-ranking}': ['p(95)<300', 'p(99)<600'], + + // 전체 에러율 + 'http_req_failed': ['rate<0.05'], + 'ranking_read_fail': ['rate<0.01'], + 'product_detail_fail': ['rate<0.01'], + 'write_event_fail': ['rate<0.05'], + 'hot_product_fail': ['rate<0.05'], + 'weekly_ranking_fail': ['rate<0.01'], + 'monthly_ranking_fail': ['rate<0.01'], }, }; -const HEADERS = { - 'X-Loopers-LoginId': 'user1', - 'X-Loopers-LoginPw': 'Test1234!', -}; +// ── 시나리오 A: 랭킹 Top-N 읽기 ─────────────────────────────────────────── -export default function () { - const today = todayDate(); +export function scenarioRankingRead() { + const today = todayDate(); const yesterday = yesterdayDate(); + const date = Math.random() < 0.8 ? today : yesterday; // 80% 오늘, 20% 어제 - // 1. 오늘 랭킹 목록 조회 - { - const res = http.get(`${BASE_URL}/api/v1/rankings?date=${today}&size=20&page=1`, { headers: HEADERS }); - const ok = check(res, { - '[오늘 랭킹] 200 응답': (r) => r.status === 200, - '[오늘 랭킹] data 필드 존재': (r) => JSON.parse(r.body).data !== undefined, - '[오늘 랭킹] content 배열 존재': (r) => { - const body = JSON.parse(r.body); - return Array.isArray(body.data.content); - }, - '[오늘 랭킹] 상품 정보 Aggregation 확인 (name, brandName 존재)': (r) => { - const body = JSON.parse(r.body); - const content = body.data.content; - if (content.length === 0) return true; // 데이터 없으면 pass - return content[0].name !== undefined && content[0].brandName !== undefined; - }, - '[오늘 랭킹] rank 필드 존재': (r) => { - const body = JSON.parse(r.body); - const content = body.data.content; - if (content.length === 0) return true; - return content[0].rank !== undefined; - }, - }); - ok ? rankingSuccess.add(1) : rankingFail.add(1); - } + const res = http.get(`${BASE_URL}/api/v1/rankings?date=${date}&size=20&page=1`); + + const ok = check(res, { + '[A] status 200': (r) => r.status === 200, + '[A] data.content 배열': (r) => { + try { return Array.isArray(JSON.parse(r.body).data.content); } catch { return false; } + }, + '[A] rank 필드 존재': (r) => { + try { + const content = JSON.parse(r.body).data.content; + return content.length === 0 || content[0].rank !== undefined; + } catch { return false; } + }, + '[A] name·brandName Aggregation': (r) => { + try { + const content = JSON.parse(r.body).data.content; + return content.length === 0 || (content[0].name !== undefined && content[0].brandName !== undefined); + } catch { return false; } + }, + }); + + rankingReadFailRate.add(!ok); + sleep(0.3); +} + +// ── 시나리오 B: 상품 상세 + rank ─────────────────────────────────────────── + +export function scenarioProductDetail() { + const productId = ((__VU % 20) + 1); // 1~20 상품 균등 분산 - sleep(0.5); + const beforeMs = Date.now(); + const res = http.get(`${BASE_URL}/api/v1/products/${productId}`); + const afterMs = Date.now(); - // 2. 이전 날짜 랭킹 조회 (어제) - { - const res = http.get(`${BASE_URL}/api/v1/rankings?date=${yesterday}&size=20&page=1`, { headers: HEADERS }); - check(res, { - '[어제 랭킹] 200 응답': (r) => r.status === 200, - '[어제 랭킹] data 필드 존재': (r) => JSON.parse(r.body).data !== undefined, - '[어제 랭킹] content 배열 존재': (r) => Array.isArray(JSON.parse(r.body).data.content), + const ok = check(res, { + '[B] status 200': (r) => r.status === 200, + '[B] rank 필드 항상 존재': (r) => { + try { return 'rank' in (JSON.parse(r.body).data || {}); } catch { return false; } + }, + '[B] rank는 숫자 또는 null': (r) => { + try { + const rank = JSON.parse(r.body).data.rank; + return rank === null || typeof rank === 'number'; + } catch { return false; } + }, + }); + + productDetailFailRate.add(!ok); + + // ZREVRANK 오버헤드를 응답시간으로 간접 측정 + zrevrankLatency.add(afterMs - beforeMs); + + sleep(0.2); +} + +// ── 시나리오 C: 이벤트 쓰기 파이프라인 ─────────────────────────────────── + +export function scenarioWritePipeline() { + const productId = ((__VU % 20) + 1); + const headers = userHeaders(__VU); + + // 조회 이벤트: GET /api/v1/products/{id} → PRODUCT_VIEWED → Kafka → ZSET + const viewRes = http.get(`${BASE_URL}/api/v1/products/${productId}`); + + const viewOk = check(viewRes, { + '[C-조회] status 200': (r) => r.status === 200, + }); + + // 좋아요 이벤트: POST /api/v1/likes → PRODUCT_LIKED → Kafka → ZSET + // 50% 확률로 좋아요 (매번 좋아요하면 conflict 발생) + let likeOk = true; + if (Math.random() < 0.5) { + const likeRes = http.post( + `${BASE_URL}/api/v1/likes`, + JSON.stringify({ productId: productId }), + { headers: { ...headers, 'Content-Type': 'application/json' } } + ); + // 200(성공), 409(이미 좋아요) 모두 정상 + likeOk = likeRes.status === 200 || likeRes.status === 409; + check(likeRes, { + '[C-좋아요] status 200 or 409': (r) => r.status === 200 || r.status === 409, }); } - sleep(0.5); - - // 3. 상품 상세 조회 시 rank 필드 확인 (productId=1) - { - const res = http.get(`${BASE_URL}/api/v1/products/1`, { headers: HEADERS }); - const ok = check(res, { - '[상품 상세] 200 응답': (r) => r.status === 200, - '[상품 상세] rank 필드 존재 (null이어도 됨)': (r) => { - const body = JSON.parse(r.body); - return 'rank' in (body.data || {}); - }, - }); - productDetailSuccess.add(ok ? 1 : 0); - rankFieldPresent.add(ok ? 1 : 0); + writeEventFailRate.add(!viewOk || !likeOk); + sleep(0.1); +} + +// ── 시나리오 D: Hot Product 경합 ────────────────────────────────────────── + +export function scenarioHotProduct() { + const productId = pickProductId(__VU, __ITER); + + // 읽기 (ZREVRANK) + 쓰기 (조회 이벤트) 혼합 + const res = http.get(`${BASE_URL}/api/v1/products/${productId}`); + + const ok = check(res, { + '[D] status 200': (r) => r.status === 200, + '[D] rank 필드 존재': (r) => { + try { return 'rank' in (JSON.parse(r.body).data || {}); } catch { return false; } + }, + }); + + hotProductFailRate.add(!ok); + + // Hot 상품(1~3)이면 랭킹 API도 함께 조회 (실제 홈 화면 패턴) + if (productId <= 3) { + const rankRes = http.get(`${BASE_URL}/api/v1/rankings?date=${todayDate()}&size=10&page=1`); + check(rankRes, { '[D] 랭킹 조회 200': (r) => r.status === 200 }); } - sleep(0.5); + sleep(0.1); +} + +// ── 시나리오 E: 주간 랭킹 조회 ──────────────────────────────────────────── + +export function scenarioWeeklyRankingRead() { + const res = http.get(`${BASE_URL}/api/v1/rankings?date=${todayDate()}&type=weekly&size=20&page=1`); + + const ok = check(res, { + '[E] status 200': (r) => r.status === 200, + '[E] data.content 배열': (r) => { + try { return Array.isArray(JSON.parse(r.body).data.content); } catch { return false; } + }, + }); + + weeklyRankingFailRate.add(!ok); + sleep(0.3); +} + +// ── 시나리오 F: 월간 랭킹 조회 ──────────────────────────────────────────── + +export function scenarioMonthlyRankingRead() { + const res = http.get(`${BASE_URL}/api/v1/rankings?date=${todayDate()}&type=monthly&size=20&page=1`); + + const ok = check(res, { + '[F] status 200': (r) => r.status === 200, + '[F] data.content 배열': (r) => { + try { return Array.isArray(JSON.parse(r.body).data.content); } catch { return false; } + }, + }); + + monthlyRankingFailRate.add(!ok); + sleep(0.3); } diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMonthly.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMonthly.java new file mode 100644 index 0000000000..35d83cc3d7 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankMonthly.java @@ -0,0 +1,62 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint(name = "uq_product_month", columnNames = {"product_id", "period_month"}) +) +@Getter +public class ProductRankMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "order_count", nullable = false) + private Long orderCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "period_month", nullable = false, length = 7) + private String yearMonth; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected ProductRankMonthly() {} + + public static ProductRankMonthly of(Long productId, double score, long likeCount, long orderCount, long viewCount, String yearMonth) { + ProductRankMonthly entity = new ProductRankMonthly(); + entity.productId = productId; + entity.rankPosition = 0; + entity.score = score; + entity.likeCount = likeCount; + entity.orderCount = orderCount; + entity.viewCount = viewCount; + entity.yearMonth = yearMonth; + entity.updatedAt = LocalDateTime.now(); + return entity; + } + + public void assignRank(int rank) { + this.rankPosition = rank; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankWeekly.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankWeekly.java new file mode 100644 index 0000000000..6ab50996b2 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankWeekly.java @@ -0,0 +1,62 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint(name = "uq_product_week", columnNames = {"product_id", "period_week"}) +) +@Getter +public class ProductRankWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "order_count", nullable = false) + private Long orderCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "period_week", nullable = false, length = 10) + private String yearWeek; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected ProductRankWeekly() {} + + public static ProductRankWeekly of(Long productId, double score, long likeCount, long orderCount, long viewCount, String yearWeek) { + ProductRankWeekly entity = new ProductRankWeekly(); + entity.productId = productId; + entity.rankPosition = 0; + entity.score = score; + entity.likeCount = likeCount; + entity.orderCount = orderCount; + entity.viewCount = viewCount; + entity.yearWeek = yearWeek; + entity.updatedAt = LocalDateTime.now(); + return entity; + } + + public void assignRank(int rank) { + this.rankPosition = rank; + } +} diff --git a/scripts/seed-metrics.sql b/scripts/seed-metrics.sql new file mode 100644 index 0000000000..bb26211df5 --- /dev/null +++ b/scripts/seed-metrics.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- product_metrics 테스트 데이터 적재 스크립트 +-- (배치 주간/월간 랭킹 테스트용) +-- +-- [실행 전 필수 조건] +-- 1. seed-data.sql 먼저 실행 (brands, products 데이터 필요) +-- 2. commerce-streamer 또는 commerce-api 가 한 번이라도 실행되어 +-- product_metrics 테이블이 생성된 상태여야 함 +-- +-- [실행 방법] +-- docker exec -i docker-mysql-1 mysql -uapplication -papplication < scripts/seed-metrics.sql +-- ============================================================ + +USE loopers; + +-- 기존 메트릭 초기화 +TRUNCATE TABLE product_metrics; + +-- 상품 ID 1~20에 임의의 메트릭 데이터 삽입 +-- score = like * 0.2 + order * 0.7 + view * 0.1 +INSERT INTO product_metrics (product_id, like_count, order_count, view_count, updated_at) VALUES +(1, 500, 300, 10000, NOW()), +(2, 800, 150, 8000, NOW()), +(3, 200, 500, 5000, NOW()), -- order 많아서 높은 순위 예상 +(4, 900, 50, 12000, NOW()), +(5, 100, 400, 3000, NOW()), +(6, 600, 250, 9000, NOW()), +(7, 50, 600, 2000, NOW()), -- order 최다 → 1위 예상 +(8, 750, 100, 7000, NOW()), +(9, 300, 350, 6000, NOW()), +(10, 450, 200, 4000, NOW()), +(11, 650, 180, 8500, NOW()), +(12, 120, 420, 3500, NOW()), +(13, 880, 80, 11000, NOW()), +(14, 230, 380, 5500, NOW()), +(15, 550, 270, 9500, NOW()), +(16, 80, 550, 1500, NOW()), +(17, 720, 130, 7500, NOW()), +(18, 390, 310, 6500, NOW()), +(19, 470, 220, 4500, NOW()), +(20, 610, 260, 9200, NOW()); + +-- 결과 확인 (예상 순위: score 내림차순) +SELECT + product_id, + like_count, + order_count, + view_count, + ROUND(like_count * 0.2 + order_count * 0.7 + view_count * 0.1, 1) AS score +FROM product_metrics +ORDER BY score DESC;