From 5798ded4b81cdf4b6399558c9a3c139294cbef44 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 14 Apr 2026 21:21:09 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20product=5Fmetrics=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20orderCount=20=E2=86=92=20orderLineCount=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/metrics/ProductMetricsReadModel.java | 4 ++-- .../main/java/com/loopers/domain/ProductMetrics.java | 2 +- .../infrastructure/ProductMetricsRepository.java | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java index 303095961..af02587ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/ProductMetricsReadModel.java @@ -22,7 +22,7 @@ public class ProductMetricsReadModel { private Long viewCount; @Column(nullable = false) - private Long orderCount; + private Long orderLineCount; protected ProductMetricsReadModel() {} -} \ No newline at end of file +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java index 8accb3b54..665fd23c2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetrics.java @@ -20,7 +20,7 @@ public class ProductMetrics { private Long viewCount = 0L; @Column(nullable = false) - private Long orderCount = 0L; + private Long orderLineCount = 0L; protected ProductMetrics() {} } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java index e98b54be6..54d0ccb67 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -11,7 +11,7 @@ public interface ProductMetricsRepository extends JpaRepository Date: Tue, 14 Apr 2026 21:22:51 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B3=84=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94(product=5Fmetrics=5Fdaily)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/PaymentCompletedEventHandler.java | 27 ++- .../handler/ProductLikedEventHandler.java | 10 +- .../handler/ProductUnlikedEventHandler.java | 10 +- .../handler/ProductViewedEventHandler.java | 10 +- .../loopers/domain/ProductMetricsDaily.java | 39 ++++ .../loopers/domain/ProductMetricsDailyId.java | 29 +++ .../ProductMetricsDailyRepository.java | 49 +++++ .../ProductMetricsDailyRepositoryTest.java | 185 ++++++++++++++++++ 8 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java index eb0bbf6b2..0344d8248 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java @@ -1,18 +1,26 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsDailyRepository; import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; import com.loopers.event.EventType; +import com.loopers.event.payload.OrderedProduct; import com.loopers.event.payload.PaymentCompletedEventPayload; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + @Component @RequiredArgsConstructor public class PaymentCompletedEventHandler implements EventHandler { private final ProductMetricsRepository productMetricsRepository; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final Clock clock; @Override public boolean supports(Event event) { @@ -21,8 +29,23 @@ public boolean supports(Event event) { @Override public void handle(Event event) { - for (Long productId : event.getPayload().getProductIds()) { - productMetricsRepository.incrementOrderCount(productId); + PaymentCompletedEventPayload payload = event.getPayload(); + LocalDate today = LocalDate.now(clock); + + for (Long productId : payload.getProductIds()) { + productMetricsRepository.incrementOrderLineCount(productId); + } + + List orderedProducts = payload.getOrderedProducts(); + if (orderedProducts == null || orderedProducts.isEmpty()) { + return; + } + + for (OrderedProduct product : orderedProducts) { + long orderAmount = product.getPrice() * product.getQuantity(); + productMetricsDailyRepository.incrementOrderLineCountAndAmount( + product.getProductId(), today, orderAmount + ); } } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java index e61854bfb..71d93c000 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductLikedEventHandler.java @@ -1,6 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsDailyRepository; import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; @@ -9,10 +10,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; + @Component @RequiredArgsConstructor public class ProductLikedEventHandler implements EventHandler { private final ProductMetricsRepository productMetricsRepository; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final Clock clock; @Override public boolean supports(Event event) { @@ -21,6 +27,8 @@ public boolean supports(Event event) { @Override public void handle(Event event) { - productMetricsRepository.incrementLikeCount(event.getPayload().getProductId()); + Long productId = event.getPayload().getProductId(); + productMetricsRepository.incrementLikeCount(productId); + productMetricsDailyRepository.incrementLikeCount(productId, LocalDate.now(clock)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java index bd46d3a40..63e56e65d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductUnlikedEventHandler.java @@ -1,6 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsDailyRepository; import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; @@ -9,10 +10,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; + @Component @RequiredArgsConstructor public class ProductUnlikedEventHandler implements EventHandler { private final ProductMetricsRepository productMetricsRepository; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final Clock clock; @Override public boolean supports(Event event) { @@ -21,6 +27,8 @@ public boolean supports(Event event) { @Override public void handle(Event event) { - productMetricsRepository.decrementLikeCount(event.getPayload().getProductId()); + Long productId = event.getPayload().getProductId(); + productMetricsRepository.decrementLikeCount(productId); + productMetricsDailyRepository.decrementLikeCount(productId, LocalDate.now(clock)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java index b3e3f2beb..e35a4cd5c 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/ProductViewedEventHandler.java @@ -1,6 +1,7 @@ package com.loopers.application.handler; import com.loopers.application.EventHandler; +import com.loopers.infrastructure.ProductMetricsDailyRepository; import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.event.Event; import com.loopers.event.EventPayload; @@ -9,10 +10,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; + @Component @RequiredArgsConstructor public class ProductViewedEventHandler implements EventHandler { private final ProductMetricsRepository productMetricsRepository; + private final ProductMetricsDailyRepository productMetricsDailyRepository; + private final Clock clock; @Override public boolean supports(Event event) { @@ -21,6 +27,8 @@ public boolean supports(Event event) { @Override public void handle(Event event) { - productMetricsRepository.incrementViewCount(event.getPayload().getProductId()); + Long productId = event.getPayload().getProductId(); + productMetricsRepository.incrementViewCount(productId); + productMetricsDailyRepository.incrementViewCount(productId, LocalDate.now(clock)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java new file mode 100644 index 000000000..7804d3f70 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDaily.java @@ -0,0 +1,39 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "product_metrics_daily") +@IdClass(ProductMetricsDailyId.class) +public class ProductMetricsDaily { + + @Id + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount = 0L; + + @Column(name = "view_count", nullable = false) + private Long viewCount = 0L; + + @Column(name = "order_line_count", nullable = false) + private Long orderLineCount = 0L; + + @Column(name = "order_amount", nullable = false) + private Long orderAmount = 0L; + + protected ProductMetricsDaily() {} +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java new file mode 100644 index 000000000..52d982180 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsDailyId.java @@ -0,0 +1,29 @@ +package com.loopers.domain; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +public class ProductMetricsDailyId implements Serializable { + private LocalDate metricDate; + private Long productId; + + protected ProductMetricsDailyId() {} + + public ProductMetricsDailyId(LocalDate metricDate, Long productId) { + this.metricDate = metricDate; + this.productId = productId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ProductMetricsDailyId that)) return false; + return Objects.equals(metricDate, that.metricDate) && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(metricDate, productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java new file mode 100644 index 000000000..efde2576b --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsDailyRepository.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetricsDaily; +import com.loopers.domain.ProductMetricsDailyId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface ProductMetricsDailyRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily (product_id, metric_date, like_count, view_count, order_line_count, order_amount) + VALUES (:productId, :metricDate, 1, 0, 0, 0) + ON DUPLICATE KEY UPDATE like_count = like_count + 1 + """, nativeQuery = true) + void incrementLikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily (product_id, metric_date, like_count, view_count, order_line_count, order_amount) + VALUES (:productId, :metricDate, -1, 0, 0, 0) + ON DUPLICATE KEY UPDATE like_count = like_count - 1 + """, nativeQuery = true) + void decrementLikeCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily (product_id, metric_date, like_count, view_count, order_line_count, order_amount) + VALUES (:productId, :metricDate, 0, 1, 0, 0) + ON DUPLICATE KEY UPDATE view_count = view_count + 1 + """, nativeQuery = true) + void incrementViewCount(@Param("productId") Long productId, @Param("metricDate") LocalDate metricDate); + + @Modifying + @Query(value = """ + INSERT INTO product_metrics_daily (product_id, metric_date, like_count, view_count, order_line_count, order_amount) + VALUES (:productId, :metricDate, 0, 0, 1, :orderAmount) + ON DUPLICATE KEY UPDATE order_line_count = order_line_count + 1, order_amount = order_amount + :orderAmount + """, nativeQuery = true) + void incrementOrderLineCountAndAmount( + @Param("productId") Long productId, + @Param("metricDate") LocalDate metricDate, + @Param("orderAmount") long orderAmount + ); +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java new file mode 100644 index 000000000..f32795104 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ProductMetricsDailyRepositoryTest.java @@ -0,0 +1,185 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.ProductMetricsDaily; +import com.loopers.domain.ProductMetricsDailyId; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.kafka.test.context.EmbeddedKafka; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@EmbeddedKafka( + partitions = 1, + brokerProperties = {"listeners=PLAINTEXT://localhost:0"}, + topics = {"catalog-events", "order-events", "coupon-issue-requests"} +) +class ProductMetricsDailyRepositoryTest { + + @Autowired + private ProductMetricsDailyRepository productMetricsDailyRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final Long PRODUCT_ID = 1L; + private static final LocalDate TODAY = LocalDate.of(2026, 4, 14); + private static final LocalDate YESTERDAY = LocalDate.of(2026, 4, 13); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("같은 상품 + 같은 날짜에 viewCount를 여러 번 증가시키면 값이 누적된다") + @Test + void incrementViewCount_sameProductSameDate_accumulates() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, TODAY); + }); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertThat(result.getViewCount()).isEqualTo(3); + } + + @DisplayName("같은 상품 + 다른 날짜는 별도 행으로 생성된다") + @Test + void incrementViewCount_sameProductDifferentDate_createsSeparateRows() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, YESTERDAY); + }); + + // assert + ProductMetricsDaily todayResult = findMetrics(PRODUCT_ID, TODAY); + ProductMetricsDaily yesterdayResult = findMetrics(PRODUCT_ID, YESTERDAY); + assertAll( + () -> assertThat(todayResult.getViewCount()).isEqualTo(1), + () -> assertThat(yesterdayResult.getViewCount()).isEqualTo(1) + ); + } + + @DisplayName("likeCount를 증가시키면 해당 날짜의 좋아요 수가 누적된다") + @Test + void incrementLikeCount_accumulates() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementLikeCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementLikeCount(PRODUCT_ID, TODAY); + }); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertThat(result.getLikeCount()).isEqualTo(2); + } + + @DisplayName("decrementLikeCount는 음수를 허용한다 (delta 방식)") + @Test + void decrementLikeCount_allowsNegative() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> + productMetricsDailyRepository.decrementLikeCount(PRODUCT_ID, TODAY) + ); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertThat(result.getLikeCount()).isEqualTo(-1); + } + + @DisplayName("like 후 unlike 하면 likeCount가 상쇄되어 0이 된다") + @Test + void likeAndUnlike_cancelsOut() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementLikeCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.decrementLikeCount(PRODUCT_ID, TODAY); + }); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertThat(result.getLikeCount()).isEqualTo(0); + } + + @DisplayName("주문 라인 집계 시 orderLineCount와 orderAmount가 함께 누적된다") + @Test + void incrementOrderLineCountAndAmount_accumulates() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, 50000); + productMetricsDailyRepository.incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, 30000); + }); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertAll( + () -> assertThat(result.getOrderLineCount()).isEqualTo(2), + () -> assertThat(result.getOrderAmount()).isEqualTo(80000) + ); + } + + @DisplayName("하나의 주문 라인에 상품 수량이 여러 개여도 orderLineCount는 1만 증가한다") + @Test + void incrementOrderLineCountAndAmount_countsOnePerOrderLine() { + // arrange + int unitPrice = 3000; + int quantity = 3; + long orderAmount = unitPrice * quantity; + + // act + transactionTemplate.executeWithoutResult(status -> + productMetricsDailyRepository.incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, orderAmount) + ); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertAll( + () -> assertThat(result.getOrderLineCount()).isEqualTo(1), + () -> assertThat(result.getOrderAmount()).isEqualTo(9000) + ); + } + + @DisplayName("조회, 좋아요, 주문 라인을 같은 날짜에 집계하면 각 메트릭이 각각 반영된다") + @Test + void multipleMetrics_workIndependently() { + // arrange & act + transactionTemplate.executeWithoutResult(status -> { + productMetricsDailyRepository.incrementViewCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementLikeCount(PRODUCT_ID, TODAY); + productMetricsDailyRepository.incrementOrderLineCountAndAmount(PRODUCT_ID, TODAY, 10000); + }); + + // assert + ProductMetricsDaily result = findMetrics(PRODUCT_ID, TODAY); + assertAll( + () -> assertThat(result.getViewCount()).isEqualTo(1), + () -> assertThat(result.getLikeCount()).isEqualTo(1), + () -> assertThat(result.getOrderLineCount()).isEqualTo(1), + () -> assertThat(result.getOrderAmount()).isEqualTo(10000) + ); + } + + private ProductMetricsDaily findMetrics(Long productId, LocalDate date) { + Optional opt = productMetricsDailyRepository.findById( + new ProductMetricsDailyId(date, productId) + ); + assertThat(opt).isPresent(); + return opt.get(); + } +} From 3ab7043f3aa66c3e57192ce44961d96dd31f3448 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 14 Apr 2026 21:23:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ranking/ProductRankSnapshot.java | 94 +++++++++++++++++++ .../loopers/domain/ranking/RankingType.java | 15 +++ .../ProductRankSnapshotJpaRepository.java | 20 ++++ modules/jpa/src/main/resources/jpa.yml | 2 +- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java create mode 100644 modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java new file mode 100644 index 000000000..df0bff11f --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/ProductRankSnapshot.java @@ -0,0 +1,94 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "product_rank_snapshot", + uniqueConstraints = { + @UniqueConstraint(name = "uk_type_date_product", + columnNames = {"ranking_type", "rank_date", "product_id"}) + }, + indexes = { + @Index(name = "idx_type_date_rank", + columnList = "ranking_type, rank_date, rank_position") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductRankSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "ranking_type", nullable = false, length = 20) + private RankingType rankingType; + + @Column(name = "rank_date", nullable = false) + private LocalDate rankDate; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "total_view_count", nullable = false) + private Long totalViewCount; + + @Column(name = "total_like_count", nullable = false) + private Long totalLikeCount; + + @Column(name = "total_order_line_count", nullable = false) + private Long totalOrderLineCount; + + @Column(name = "total_order_amount", nullable = false) + private Long totalOrderAmount; + + @Column(name = "score", nullable = false) + private Double score; + + @Builder + public ProductRankSnapshot(RankingType rankingType, LocalDate rankDate, Integer rankPosition, + Long productId, String productName, Integer price, String brandName, + Long totalViewCount, Long totalLikeCount, + Long totalOrderLineCount, Long totalOrderAmount, Double score) { + this.rankingType = rankingType; + this.rankDate = rankDate; + this.rankPosition = rankPosition; + this.productId = productId; + this.productName = productName; + this.price = price; + this.brandName = brandName; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalOrderLineCount = totalOrderLineCount; + this.totalOrderAmount = totalOrderAmount; + this.score = score; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java b/modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java new file mode 100644 index 000000000..f0e8410f7 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/ranking/RankingType.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +public enum RankingType { + DAILY, + WEEKLY, + MONTHLY; + + public int getDays() { + return switch (this) { + case DAILY -> 1; + case WEEKLY -> 7; + case MONTHLY -> 30; + }; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java b/modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java new file mode 100644 index 000000000..0adf8ee1e --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.RankingType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface ProductRankSnapshotJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM ProductRankSnapshot p WHERE p.rankingType = :rankingType AND p.rankDate = :rankDate") + void deleteByRankingTypeAndRankDate( + @Param("rankingType") RankingType rankingType, + @Param("rankDate") LocalDate rankDate + ); +} diff --git a/modules/jpa/src/main/resources/jpa.yml b/modules/jpa/src/main/resources/jpa.yml index 266522e62..37f4fb1b0 100644 --- a/modules/jpa/src/main/resources/jpa.yml +++ b/modules/jpa/src/main/resources/jpa.yml @@ -37,7 +37,7 @@ spring: jpa: show-sql: true hibernate: - ddl-auto: none + ddl-auto: create datasource: mysql-jpa: From be397e418d6572a0be3cc08070ca86d8058dd310 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 14 Apr 2026 21:24:34 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9E=A1=20=EA=B5=AC=ED=98=84=20(chunk=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/RankingAggregationJobConfig.java | 178 +++++++++ .../src/main/resources/application.yml | 6 + .../ranking/RankingAggregationJobE2ETest.java | 364 ++++++++++++++++++ 3 files changed, 548 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java new file mode 100644 index 000000000..62ea429cf --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java @@ -0,0 +1,178 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.listener.ChunkListener; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.RankingType; +import com.loopers.infrastructure.ranking.ProductRankSnapshotJpaRepository; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +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.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.time.LocalDate; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingAggregationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankingAggregationJobConfig { + public static final String JOB_NAME = "rankingAggregationJob"; + private static final String STEP_NAME = "aggregateRankingStep"; + private static final int CHUNK_SIZE = 100; + + private static final String READER_SQL = """ + SELECT * + FROM ( + SELECT + pm.product_id, + p.name AS product_name, + p.price, + b.name AS brand_name, + SUM(pm.view_count) AS total_view_count, + SUM(pm.like_count) AS total_like_count, + SUM(pm.order_line_count) AS total_order_line_count, + SUM(pm.order_amount) AS total_order_amount, + (SUM(pm.view_count) * ? + SUM(pm.like_count) * ? + SUM(pm.order_amount) * ?) AS score, + ROW_NUMBER() OVER ( + ORDER BY (SUM(pm.view_count) * ? + SUM(pm.like_count) * ? + SUM(pm.order_amount) * ?) DESC, + pm.product_id ASC + ) AS rank_position + FROM product_metrics_daily pm + JOIN products p ON pm.product_id = p.id + AND p.deleted_at IS NULL + AND p.visibility = 'VISIBLE' + JOIN brands b ON p.brand_id = b.id + AND b.deleted_at IS NULL + WHERE pm.metric_date BETWEEN ? AND ? + GROUP BY pm.product_id, p.name, p.price, b.name + ) ranked + WHERE rank_position <= 100 + """; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ChunkListener chunkListener; + private final PlatformTransactionManager transactionManager; + private final EntityManagerFactory entityManagerFactory; + private final ProductRankSnapshotJpaRepository productRankSnapshotJpaRepository; + + @Bean(JOB_NAME) + public Job rankingAggregationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(aggregateRankingStep(null)) + .listener(jobListener) + .build(); + } + + @Bean(STEP_NAME) + public Step aggregateRankingStep(JdbcCursorItemReader rankingAggregationReader) { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(rankingAggregationReader) + .writer(rankingWriter()) + .listener(stepMonitorListener) + .listener(chunkListener) + .listener(deleteOldRankingStepListener(null, null)) + .build(); + } + + @StepScope + @Bean + public JdbcCursorItemReader rankingAggregationReader( + DataSource dataSource, + @Value("#{jobParameters['rankingType']}") String rankingTypeStr, + @Value("#{jobParameters['endDate']}") LocalDate endDate, + @Value("${ranking.weight.view:0.1}") double viewWeight, + @Value("${ranking.weight.like:0.2}") double likeWeight, + @Value("${ranking.weight.order:0.7}") double orderWeight + ) { + RankingType rankingType = RankingType.valueOf(rankingTypeStr); + LocalDate actualEndDate = endDate != null ? endDate : LocalDate.now().minusDays(1); + LocalDate startDate = actualEndDate.minusDays(rankingType.getDays() - 1); + + return new JdbcCursorItemReaderBuilder() + .name("rankingAggregationReader") + .dataSource(dataSource) + .sql(READER_SQL) + .preparedStatementSetter(ps -> { + ps.setDouble(1, viewWeight); + ps.setDouble(2, likeWeight); + ps.setDouble(3, orderWeight); + ps.setDouble(4, viewWeight); + ps.setDouble(5, likeWeight); + ps.setDouble(6, orderWeight); + ps.setObject(7, startDate); + ps.setObject(8, actualEndDate); + }) + .rowMapper((rs, rowNum) -> ProductRankSnapshot.builder() + .rankingType(rankingType) + .rankDate(actualEndDate) + .rankPosition(rs.getInt("rank_position")) + .productId(rs.getLong("product_id")) + .productName(rs.getString("product_name")) + .price(rs.getInt("price")) + .brandName(rs.getString("brand_name")) + .totalViewCount(rs.getLong("total_view_count")) + .totalLikeCount(rs.getLong("total_like_count")) + .totalOrderLineCount(rs.getLong("total_order_line_count")) + .totalOrderAmount(rs.getLong("total_order_amount")) + .score(rs.getDouble("score")) + .build()) + .build(); + } + + @StepScope + @Bean + public StepExecutionListener deleteOldRankingStepListener( + @Value("#{jobParameters['rankingType']}") String rankingTypeStr, + @Value("#{jobParameters['endDate']}") LocalDate endDate + ) { + return new StepExecutionListener() { + @Override + @Transactional + public void beforeStep(StepExecution stepExecution) { + RankingType rankingType = RankingType.valueOf(rankingTypeStr); + LocalDate actualEndDate = endDate != null ? endDate : LocalDate.now().minusDays(1); + log.info("기존 랭킹 데이터 삭제: rankingType={}, rankDate={}", rankingType, actualEndDate); + productRankSnapshotJpaRepository.deleteByRankingTypeAndRankDate(rankingType, actualEndDate); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + return stepExecution.getExitStatus(); + } + }; + } + + @Bean + public JpaItemWriter rankingWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .usePersist(true) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760a..881e46596 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -17,6 +17,12 @@ spring: jdbc: initialize-schema: never +ranking: + weight: + view: 0.1 + like: 0.2 + order: 0.7 + management: health: defaults: diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java new file mode 100644 index 000000000..cb20b0417 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java @@ -0,0 +1,364 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.RankingAggregationJobConfig; +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.RankingType; +import com.loopers.infrastructure.ranking.ProductRankSnapshotJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = { + "spring.batch.job.name=" + RankingAggregationJobConfig.JOB_NAME, + "spring.batch.job.enabled=false" +}) +class RankingAggregationJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankingAggregationJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductRankSnapshotJpaRepository productRankSnapshotJpaRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final LocalDate END_DATE = LocalDate.of(2026, 4, 14); + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + createExternalTables(); + } + + @AfterEach + void tearDown() { + truncateExternalTables(); + databaseCleanUp.truncateAllTables(); + cleanUpBatchMetadata(); + } + + private void createExternalTables() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + CREATE TABLE IF NOT EXISTS brands ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) + ) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + CREATE TABLE IF NOT EXISTS products ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(255), + price INT NOT NULL, + stock_quantity INT NOT NULL DEFAULT 0, + visibility VARCHAR(20) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) + ) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + CREATE TABLE IF NOT EXISTS product_metrics_daily ( + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + order_line_count BIGINT NOT NULL DEFAULT 0, + order_amount BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (metric_date, product_id) + ) + """).executeUpdate(); + }); + } + + private void truncateExternalTables() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE product_metrics_daily").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE products").executeUpdate(); + entityManager.createNativeQuery("TRUNCATE TABLE brands").executeUpdate(); + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + }); + } + + private void cleanUpBatchMetadata() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery("DELETE FROM BATCH_STEP_EXECUTION_CONTEXT").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM BATCH_STEP_EXECUTION").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION_CONTEXT").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION_PARAMS").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM BATCH_JOB_EXECUTION").executeUpdate(); + entityManager.createNativeQuery("DELETE FROM BATCH_JOB_INSTANCE").executeUpdate(); + }); + } + + @DisplayName("WEEKLY 타입: 최근 7일 범위의 일별 메트릭을 집계하여 랭킹을 생성한다") + @Test + void weeklyAggregation_aggregates7Days() throws Exception { + // arrange + seedTestData(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertAll( + () -> assertThat(results).isNotEmpty(), + () -> assertThat(results).allMatch(r -> r.getRankingType() == RankingType.WEEKLY), + () -> assertThat(results).allMatch(r -> r.getRankDate().equals(END_DATE)), + () -> assertThat(results.get(0).getRankPosition()).isEqualTo(1), + () -> assertThat(results.get(0).getScore()).isGreaterThan(results.get(1).getScore()) + ); + } + + @DisplayName("MONTHLY 타입: 최근 30일 범위의 일별 메트릭을 집계하여 랭킹을 생성한다") + @Test + void monthlyAggregation_aggregates30Days() throws Exception { + // arrange + seedTestDataForMonthly(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "MONTHLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertAll( + () -> assertThat(results).isNotEmpty(), + () -> assertThat(results).allMatch(r -> r.getRankingType() == RankingType.MONTHLY), + () -> assertThat(results).allMatch(r -> r.getRankDate().equals(END_DATE)) + ); + } + + @DisplayName("멱등성: 같은 파라미터로 2회 실행해도 중복 데이터 없이 동일 결과를 반환한다") + @Test + void idempotency_sameParamsTwice_noDuplicates() throws Exception { + // arrange + seedTestData(); + + // act - 1차 실행 + jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + List firstRunResults = productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList(); + + // 2차 실행을 위해 배치 메타데이터 초기화 (같은 파라미터로 재실행 허용) + cleanUpBatchMetadata(); + + // act - 2차 실행 + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList()) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id") + .containsExactlyElementsOf(firstRunResults) + ); + } + + @DisplayName("삭제된 상품은 랭킹에서 제외된다") + @Test + void excludesDeletedProducts() throws Exception { + // arrange + seedTestDataWithDeletedProduct(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertThat(results).noneMatch(r -> r.getProductId().equals(99L)); + } + + @DisplayName("순위는 score 내림차순으로 정렬된다") + @Test + void rankOrderedByScoreDescending() throws Exception { + // arrange + seedTestData(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList(); + for (int i = 0; i < results.size() - 1; i++) { + assertThat(results.get(i).getScore()).isGreaterThanOrEqualTo(results.get(i + 1).getScore()); + } + } + + private void seedTestData() { + transactionTemplate.executeWithoutResult(status -> { + // brands + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)), + (2, '브랜드B', NOW(6), NOW(6)) + """).executeUpdate(); + + // products (VISIBLE) + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '상품A', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (2, '상품B', 20000, 2, 'VISIBLE', NOW(6), NOW(6)), + (3, '상품C', 30000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + // product_metrics_daily (7일 범위 내) + LocalDate d1 = END_DATE; + LocalDate d2 = END_DATE.minusDays(3); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (1, :d2, 200, 30, 5, 250000), + (2, :d1, 50, 100, 20, 1000000), + (3, :d1, 10, 5, 1, 30000) + """) + .setParameter("d1", d1) + .setParameter("d2", d2) + .executeUpdate(); + }); + } + + private void seedTestDataForMonthly() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '상품A', 10000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + // 30일 범위 내 데이터 (15일 전 + 25일 전) + LocalDate d1 = END_DATE.minusDays(15); + LocalDate d2 = END_DATE.minusDays(25); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (1, :d2, 200, 30, 5, 250000) + """) + .setParameter("d1", d1) + .setParameter("d2", d2) + .executeUpdate(); + }); + } + + private void seedTestDataWithDeletedProduct() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '정상상품', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (99, '삭제된상품', 20000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + // 상품 99 삭제 처리 + entityManager.createNativeQuery(""" + UPDATE products SET deleted_at = NOW(6) WHERE id = 99 + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (99, :d1, 999, 999, 999, 9999999) + """) + .setParameter("d1", END_DATE) + .executeUpdate(); + }); + } +} From 2f0b8a0f755b6c4ce95c6ab2fdfcc262b7b6afd4 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Tue, 14 Apr 2026 21:35:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20PaymentCompletedEventPayload?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20=ED=95=84?= =?UTF-8?q?=EB=93=9C(userId,=20productIds)=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/payment/PaymentFacade.java | 5 +---- .../application/payment/PaymentFacadeTest.java | 1 - .../handler/PaymentCompletedEventHandler.java | 12 ++++-------- .../event/payload/PaymentCompletedEventPayload.java | 2 -- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java index a9f2dcbaf..1f2ac5dba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -133,16 +133,13 @@ private void applyPgResult(Payment payment, PgTransactionStatus status, String r if (affected > 0) { orderService.markOrderPaid(payment.getOrderId()); List orderItems = orderService.getOrderItems(payment.getOrderId()); - List productIds = orderItems.stream() - .map(OrderItemInfo::productId) - .toList(); List orderedProducts = orderItems.stream() .map(item -> OrderedProduct.of(item.productId(), item.price(), item.quantity())) .toList(); outboxEventPublisher.publish( EventType.PAYMENT_COMPLETED, - PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), null, productIds, orderedProducts), + PaymentCompletedEventPayload.of(payment.getId(), payment.getOrderId(), orderedProducts), payment.getOrderId() ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java index 698eb959b..6c52d0f07 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -150,7 +150,6 @@ void publishesPaymentEventWithOrderedProducts_whenSuccess() { PaymentCompletedEventPayload payload = captor.getValue(); assertAll( - () -> assertThat(payload.getProductIds()).containsExactly(1L, 2L), () -> assertThat(payload.getOrderedProducts()).hasSize(2), () -> assertThat(payload.getOrderedProducts()) .extracting("productId", "price", "quantity") diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java index 0344d8248..5f419acc3 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/handler/PaymentCompletedEventHandler.java @@ -29,19 +29,15 @@ public boolean supports(Event event) { @Override public void handle(Event event) { - PaymentCompletedEventPayload payload = event.getPayload(); - LocalDate today = LocalDate.now(clock); - - for (Long productId : payload.getProductIds()) { - productMetricsRepository.incrementOrderLineCount(productId); - } - - List orderedProducts = payload.getOrderedProducts(); + List orderedProducts = event.getPayload().getOrderedProducts(); if (orderedProducts == null || orderedProducts.isEmpty()) { return; } + LocalDate today = LocalDate.now(clock); for (OrderedProduct product : orderedProducts) { + productMetricsRepository.incrementOrderLineCount(product.getProductId()); + long orderAmount = product.getPrice() * product.getQuantity(); productMetricsDailyRepository.incrementOrderLineCountAndAmount( product.getProductId(), today, orderAmount diff --git a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java index 30f54caee..5a102994a 100644 --- a/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java +++ b/modules/event-contract/src/main/java/com/loopers/event/payload/PaymentCompletedEventPayload.java @@ -13,7 +13,5 @@ public class PaymentCompletedEventPayload implements EventPayload { private Long paymentId; private Long orderId; - private Long userId; - private List productIds; private List orderedProducts; } From 5bcf053a3fbab1bc014644b841db23c4585c2283 Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 16 Apr 2026 15:48:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20API=EC=97=90?= =?UTF-8?q?=20rankingType=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(WEEKLY/MONTHLY=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EC=A1=B0=ED=9A=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 74 +++-- .../ProductRankSnapshotQueryRepository.java | 10 + ...roductRankSnapshotQueryRepositoryImpl.java | 43 +++ .../api/ranking/RankingV1Controller.java | 16 +- .../ranking/RankingFacadeTest.java | 271 ++++++++++++------ .../interfaces/api/RankingV1ApiE2ETest.java | 157 ++++++++++ 6 files changed, 472 insertions(+), 99 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java 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 32c620615..2f24ba694 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 @@ -2,10 +2,13 @@ import com.loopers.application.product.ProductFacade; import com.loopers.application.product.ProductInfo; +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.ProductRankSnapshotQueryRepository; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingInfo; -import com.loopers.event.ranking.RankingKeyGenerator; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.RankingType; +import com.loopers.event.ranking.RankingKeyGenerator; import com.loopers.support.error.CoreException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,22 +25,72 @@ @Service public class RankingFacade { private final RankingRepository rankingRepository; + private final ProductRankSnapshotQueryRepository snapshotQueryRepository; private final ProductFacade productFacade; private final Clock clock; - public RankingPageResult getRankings(LocalDate date, int page, int size) { + public RankingPageResult getRankings(LocalDate date, RankingType rankingType, int page, int size) { + if (rankingType == RankingType.DAILY) { + return getDailyRankings(date, page, size); + } + + return getSnapshotRankings(rankingType, date, page, size); + } + + public RankingInfo getProductRank(Long productId) { + LocalDate today = LocalDate.now(clock); + String key = RankingKeyGenerator.keyOf(today); + Long rank = rankingRepository.getRank(key, productId); + if (rank == null) { + return null; + } + + Double score = rankingRepository.getScore(key, productId); + return new RankingInfo(rank + 1, score); + } + + private RankingPageResult getDailyRankings(LocalDate date, int page, int size) { String key = RankingKeyGenerator.keyOf(date); int offset = (page - 1) * size; List entries = rankingRepository.getTopRankings(key, offset, size); List items = entries.stream() - .map(entry -> toRankingProductInfo(entry).orElse(null)) - .filter(Objects::nonNull) - .toList(); + .map(entry -> toRankingProductInfo(entry).orElse(null)) + .filter(Objects::nonNull) + .toList(); + + return new RankingPageResult(items, page, size); + } + + private RankingPageResult getSnapshotRankings(RankingType rankingType, LocalDate date, int page, int size) { + LocalDate rankDate = (date != null) ? date + : snapshotQueryRepository.findLatestRankDate(rankingType).orElse(null); + + if (rankDate == null) { + return new RankingPageResult(List.of(), page, size); + } + + int offset = (page - 1) * size; + List snapshots = snapshotQueryRepository.findRankings(rankingType, rankDate, offset, size); + + List items = snapshots.stream() + .map(this::toRankingProductInfo) + .toList(); return new RankingPageResult(items, page, size); } + private RankingProductInfo toRankingProductInfo(ProductRankSnapshot snapshot) { + return new RankingProductInfo( + snapshot.getProductId(), + snapshot.getProductName(), + snapshot.getPrice(), + snapshot.getBrandName(), + (long) snapshot.getRankPosition(), + snapshot.getScore() + ); + } + private Optional toRankingProductInfo(RankingEntry entry) { try { ProductInfo product = productFacade.getActiveProduct(entry.productId()); @@ -47,15 +100,4 @@ private Optional toRankingProductInfo(RankingEntry entry) { return Optional.empty(); } } - - public RankingInfo getProductRank(Long productId) { - LocalDate today = LocalDate.now(clock); - String key = RankingKeyGenerator.keyOf(today); - Long rank = rankingRepository.getRank(key, productId); - if (rank == null) { - return null; - } - Double score = rankingRepository.getScore(key, productId); - return new RankingInfo(rank + 1, score); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java new file mode 100644 index 000000000..a9bd5901a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankSnapshotQueryRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface ProductRankSnapshotQueryRepository { + List findRankings(RankingType rankingType, LocalDate rankDate, int offset, int size); + Optional findLatestRankDate(RankingType rankingType); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java new file mode 100644 index 000000000..6a23e3e36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankSnapshotQueryRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.ProductRankSnapshotQueryRepository; +import com.loopers.domain.ranking.RankingType; +import com.loopers.infrastructure.ranking.ProductRankSnapshotJpaRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class ProductRankSnapshotQueryRepositoryImpl implements ProductRankSnapshotQueryRepository { + + private final EntityManager entityManager; + + @Override + public List findRankings(RankingType rankingType, LocalDate rankDate, int offset, int size) { + return entityManager.createQuery( + "SELECT p FROM ProductRankSnapshot p " + + "WHERE p.rankingType = :rankingType AND p.rankDate = :rankDate " + + "ORDER BY p.rankPosition ASC", ProductRankSnapshot.class) + .setParameter("rankingType", rankingType) + .setParameter("rankDate", rankDate) + .setFirstResult(offset) + .setMaxResults(size) + .getResultList(); + } + + @Override + public Optional findLatestRankDate(RankingType rankingType) { + List result = entityManager.createQuery( + "SELECT MAX(p.rankDate) FROM ProductRankSnapshot p " + + "WHERE p.rankingType = :rankingType", LocalDate.class) + .setParameter("rankingType", rankingType) + .getResultList(); + return result.isEmpty() ? Optional.empty() : Optional.ofNullable(result.get(0)); + } +} 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 032224192..47b7165ac 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.RankingPageResult; +import com.loopers.domain.ranking.RankingType; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; import lombok.RequiredArgsConstructor; @@ -26,11 +27,12 @@ public class RankingV1Controller { @GetMapping public ApiResponse> getRankings( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(defaultValue = "DAILY") RankingType rankingType, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { - LocalDate targetDate = (date != null) ? date : LocalDate.now(clock); - RankingPageResult result = rankingFacade.getRankings(targetDate, page, size); + LocalDate targetDate = resolveDate(date, rankingType); + RankingPageResult result = rankingFacade.getRankings(targetDate, rankingType, page, size); List responses = result.items().stream() .map(RankingV1Dto.RankingResponse::from) @@ -38,4 +40,14 @@ public ApiResponse> getRankings( return ApiResponse.success(responses); } + + private LocalDate resolveDate(LocalDate date, RankingType rankingType) { + if (date != null) { + return date; + } + if (rankingType == RankingType.DAILY) { + return LocalDate.now(clock); + } + return null; // WEEKLY/MONTHLY: null이면 Facade에서 최신 rank_date 조회 + } } 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 e25b80f62..fc9e9e2d7 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,21 +4,25 @@ import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Product; import com.loopers.domain.ranking.InMemoryRankingRepository; +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.ProductRankSnapshotQueryRepository; import com.loopers.domain.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingType; import com.loopers.event.ranking.RankingKeyGenerator; 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 java.time.Clock; -import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.BDDMockito.given; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; class RankingFacadeTest { @@ -30,14 +34,16 @@ class RankingFacadeTest { ); private InMemoryRankingRepository rankingRepository; + private ProductRankSnapshotQueryRepository snapshotQueryRepository; private ProductFacade productFacade; private RankingFacade rankingFacade; @BeforeEach void setUp() { rankingRepository = new InMemoryRankingRepository(); + snapshotQueryRepository = mock(ProductRankSnapshotQueryRepository.class); productFacade = mock(ProductFacade.class); - rankingFacade = new RankingFacade(rankingRepository, productFacade, FIXED_CLOCK); + rankingFacade = new RankingFacade(rankingRepository, snapshotQueryRepository, productFacade, FIXED_CLOCK); } private ProductInfo stubProduct(Long id, String name, int price) { @@ -45,64 +51,92 @@ private ProductInfo stubProduct(Long id, String name, int price) { price, 100, 0, Product.Visibility.VISIBLE, null, null, null); } - @DisplayName("랭킹 페이지 조회 시 점수 높은 순으로 상품 정보와 함께 반환한다.") - @Test - void getRankings_returnsRankedProductInfo() { - // arrange - LocalDate date = LocalDate.of(2025, 4, 9); - String key = RankingKeyGenerator.keyOf(date); - long highestRankedProductId = 1L; - long secondRankedProductId = 3L; - long lowerRankedProductId = 2L; - double highestScore = 300.0; - double secondScore = 200.0; - double lowerScore = 100.0; - int highestProductPrice = 10_000; - int secondProductPrice = 30_000; + @DisplayName("일간 랭킹을 조회하면, ") + @Nested + class DailyRankings { - rankingRepository.addScore(key, highestRankedProductId, highestScore); - rankingRepository.addScore(key, lowerRankedProductId, lowerScore); - rankingRepository.addScore(key, secondRankedProductId, secondScore); + @DisplayName("Redis에서 점수 높은 순으로 상품 정보와 함께 반환한다.") + @Test + void getRankings_returnsRankedProductInfo() { + // arrange + LocalDate date = LocalDate.of(2025, 4, 9); + String key = RankingKeyGenerator.keyOf(date); + long highestRankedProductId = 1L; + long secondRankedProductId = 3L; + long lowerRankedProductId = 2L; + double highestScore = 300.0; + double secondScore = 200.0; + double lowerScore = 100.0; + int highestProductPrice = 10_000; + int secondProductPrice = 30_000; - given(productFacade.getActiveProduct(highestRankedProductId)) - .willReturn(stubProduct(highestRankedProductId, "상품A", highestProductPrice)); - given(productFacade.getActiveProduct(secondRankedProductId)) - .willReturn(stubProduct(secondRankedProductId, "상품C", secondProductPrice)); + rankingRepository.addScore(key, highestRankedProductId, highestScore); + rankingRepository.addScore(key, lowerRankedProductId, lowerScore); + rankingRepository.addScore(key, secondRankedProductId, secondScore); - // act - RankingPageResult result = rankingFacade.getRankings(date, 1, 2); + given(productFacade.getActiveProduct(highestRankedProductId)) + .willReturn(stubProduct(highestRankedProductId, "상품A", highestProductPrice)); + given(productFacade.getActiveProduct(secondRankedProductId)) + .willReturn(stubProduct(secondRankedProductId, "상품C", secondProductPrice)); - // assert - assertThat(result.page()).isEqualTo(1); - assertThat(result.size()).isEqualTo(2); - assertThat(result.items()) - .extracting( - RankingProductInfo::productId, - RankingProductInfo::productName, - RankingProductInfo::price, - RankingProductInfo::brandName, - RankingProductInfo::rank, - RankingProductInfo::score - ) - .containsExactly( - tuple(highestRankedProductId, "상품A", highestProductPrice, "브랜드", 1L, highestScore), - tuple(secondRankedProductId, "상품C", secondProductPrice, "브랜드", 2L, secondScore) - ); - } + // act + RankingPageResult result = rankingFacade.getRankings(date, RankingType.DAILY, 1, 2); - @DisplayName("빈 랭킹 조회 시 빈 리스트를 반환한다.") - @Test - void getRankings_emptyRanking() { - int requestedPage = 1; - int requestedSize = 20; + // assert + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(2); + assertThat(result.items()) + .extracting( + RankingProductInfo::productId, + RankingProductInfo::productName, + RankingProductInfo::price, + RankingProductInfo::brandName, + RankingProductInfo::rank, + RankingProductInfo::score + ) + .containsExactly( + tuple(highestRankedProductId, "상품A", highestProductPrice, "브랜드", 1L, highestScore), + tuple(secondRankedProductId, "상품C", secondProductPrice, "브랜드", 2L, secondScore) + ); + } - // act - RankingPageResult result = rankingFacade.getRankings(LocalDate.of(2025, 1, 1), requestedPage, requestedSize); + @DisplayName("해당 날짜에 데이터가 없으면 빈 리스트를 반환한다.") + @Test + void getRankings_emptyRanking() { + // act + RankingPageResult result = rankingFacade.getRankings(LocalDate.of(2025, 1, 1), RankingType.DAILY, 1, 20); - // assert - assertThat(result.items()).isEmpty(); - assertThat(result.page()).isEqualTo(requestedPage); - assertThat(result.size()).isEqualTo(requestedSize); + // assert + assertThat(result.items()).isEmpty(); + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(20); + } + + @DisplayName("삭제된 상품이 랭킹에 남아있으면 해당 항목을 건너뛰고 나머지를 반환한다.") + @Test + void getRankings_skipsStaleEntry() { + // arrange + LocalDate date = FIXED_DATE; + String key = RankingKeyGenerator.keyOf(date); + long activeProductId = 1L; + long deletedProductId = 2L; + + rankingRepository.addScore(key, deletedProductId, 500.0); + rankingRepository.addScore(key, activeProductId, 300.0); + + given(productFacade.getActiveProduct(deletedProductId)) + .willThrow(new com.loopers.support.error.CoreException( + com.loopers.support.error.ErrorType.NOT_FOUND, "삭제된 상품")); + given(productFacade.getActiveProduct(activeProductId)) + .willReturn(stubProduct(activeProductId, "활성상품", 10_000)); + + // act + RankingPageResult result = rankingFacade.getRankings(date, RankingType.DAILY, 1, 10); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productId()).isEqualTo(activeProductId); + } } @DisplayName("특정 상품의 오늘 랭킹을 조회한다. (1-based)") @@ -113,20 +147,17 @@ void getProductRank_returns1BasedRank() { long topRankedProductId = 1L; long targetProductId = 3L; long lowerRankedProductId = 2L; - double topScore = 300.0; - double targetScore = 200.0; - double lowerScore = 100.0; - rankingRepository.addScore(key, topRankedProductId, topScore); - rankingRepository.addScore(key, lowerRankedProductId, lowerScore); - rankingRepository.addScore(key, targetProductId, targetScore); + rankingRepository.addScore(key, topRankedProductId, 300.0); + rankingRepository.addScore(key, lowerRankedProductId, 100.0); + rankingRepository.addScore(key, targetProductId, 200.0); // act RankingInfo result = rankingFacade.getProductRank(targetProductId); // assert assertThat(result.rank()).isEqualTo(2L); - assertThat(result.score()).isEqualTo(targetScore); + assertThat(result.score()).isEqualTo(200.0); } @DisplayName("랭킹에 없는 상품 조회 시 null을 반환한다.") @@ -143,29 +174,107 @@ void getProductRank_returnsNullWhenNotRanked() { assertThat(result).isNull(); } - @DisplayName("삭제된 상품이 랭킹에 남아있으면 해당 항목을 건너뛰고 나머지를 반환한다.") - @Test - void getRankings_skipsStaleEntry() { - // arrange - LocalDate date = FIXED_DATE; - String key = RankingKeyGenerator.keyOf(date); - long activeProductId = 1L; - long deletedProductId = 2L; + @DisplayName("주간/월간 랭킹을 조회하면, ") + @Nested + class SnapshotRankings { - rankingRepository.addScore(key, deletedProductId, 500.0); - rankingRepository.addScore(key, activeProductId, 300.0); + @DisplayName("date 미지정 시 가장 최근 배치 결과(rank_date)의 스냅샷을 반환한다.") + @Test + void getWeeklyRankings_returnsSnapshotData() { + // arrange + LocalDate rankDate = LocalDate.of(2025, 4, 8); + given(snapshotQueryRepository.findLatestRankDate(RankingType.WEEKLY)) + .willReturn(Optional.of(rankDate)); + given(snapshotQueryRepository.findRankings(RankingType.WEEKLY, rankDate, 0, 10)) + .willReturn(List.of( + createSnapshot(RankingType.WEEKLY, rankDate, 1, 101L, "상품A", 10000, "브랜드A", 500.0), + createSnapshot(RankingType.WEEKLY, rankDate, 2, 102L, "상품B", 20000, "브랜드B", 300.0) + )); - given(productFacade.getActiveProduct(deletedProductId)) - .willThrow(new com.loopers.support.error.CoreException( - com.loopers.support.error.ErrorType.NOT_FOUND, "삭제된 상품")); - given(productFacade.getActiveProduct(activeProductId)) - .willReturn(stubProduct(activeProductId, "활성상품", 10_000)); + // act + RankingPageResult result = rankingFacade.getRankings(null, RankingType.WEEKLY, 1, 10); - // act - RankingPageResult result = rankingFacade.getRankings(date, 1, 10); + // assert + assertThat(result.items()).hasSize(2); + assertThat(result.items()) + .extracting(RankingProductInfo::productId, RankingProductInfo::rank, RankingProductInfo::score) + .containsExactly( + tuple(101L, 1L, 500.0), + tuple(102L, 2L, 300.0) + ); + } - // assert - assertThat(result.items()).hasSize(1); - assertThat(result.items().get(0).productId()).isEqualTo(activeProductId); + @DisplayName("date 지정 시 해당 날짜의 스냅샷을 반환한다.") + @Test + void getMonthlyRankings_withSpecificDate() { + // arrange + LocalDate specificDate = LocalDate.of(2025, 3, 31); + given(snapshotQueryRepository.findRankings(RankingType.MONTHLY, specificDate, 0, 5)) + .willReturn(List.of( + createSnapshot(RankingType.MONTHLY, specificDate, 1, 201L, "인기상품", 50000, "인기브랜드", 1000.0) + )); + + // act + RankingPageResult result = rankingFacade.getRankings(specificDate, RankingType.MONTHLY, 1, 5); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productName()).isEqualTo("인기상품"); + assertThat(result.items().get(0).brandName()).isEqualTo("인기브랜드"); + } + + @DisplayName("배치 결과가 없으면 빈 리스트를 반환한다.") + @Test + void getWeeklyRankings_returnsEmptyWhenNoData() { + // arrange + given(snapshotQueryRepository.findLatestRankDate(RankingType.WEEKLY)) + .willReturn(Optional.empty()); + + // act + RankingPageResult result = rankingFacade.getRankings(null, RankingType.WEEKLY, 1, 10); + + // assert + assertThat(result.items()).isEmpty(); + assertThat(result.page()).isEqualTo(1); + } + + @DisplayName("page/size에 따라 offset 기반 페이지네이션이 동작한다.") + @Test + void getWeeklyRankings_pagination() { + // arrange + LocalDate rankDate = LocalDate.of(2025, 4, 8); + given(snapshotQueryRepository.findLatestRankDate(RankingType.WEEKLY)) + .willReturn(Optional.of(rankDate)); + given(snapshotQueryRepository.findRankings(RankingType.WEEKLY, rankDate, 2, 2)) + .willReturn(List.of( + createSnapshot(RankingType.WEEKLY, rankDate, 3, 103L, "상품C", 30000, "브랜드C", 100.0) + )); + + // act + RankingPageResult result = rankingFacade.getRankings(null, RankingType.WEEKLY, 2, 2); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).rank()).isEqualTo(3L); + assertThat(result.page()).isEqualTo(2); + } + + private ProductRankSnapshot createSnapshot(RankingType type, LocalDate rankDate, int position, + Long productId, String name, int price, String brand, double score) { + return ProductRankSnapshot.builder() + .rankingType(type) + .rankDate(rankDate) + .rankPosition(position) + .productId(productId) + .productName(name) + .price(price) + .brandName(brand) + .totalViewCount(0L) + .totalLikeCount(0L) + .totalOrderLineCount(0L) + .totalOrderAmount(0L) + .score(score) + .build(); + } } } 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 index dd629e4db..03f14f612 100644 --- 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 @@ -2,9 +2,12 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; +import com.loopers.domain.ranking.ProductRankSnapshot; +import com.loopers.domain.ranking.RankingType; import com.loopers.event.ranking.RankingKeyGenerator; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.ranking.ProductRankSnapshotJpaRepository; import com.loopers.interfaces.api.product.dto.ProductV1Dto; import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -52,6 +55,9 @@ class RankingV1ApiE2ETest { @Autowired private RedisCleanUp redisCleanUp; + @Autowired + private ProductRankSnapshotJpaRepository productRankSnapshotJpaRepository; + @Autowired private Clock clock; @@ -251,6 +257,157 @@ void skipsDeletedProductInRanking() { } } + @DisplayName("rankingType 파라미터로 조회시, ") + @Nested + class GetSnapshotRankings { + + private ProductRankSnapshot saveSnapshot(RankingType type, LocalDate rankDate, int position, + Long productId, String name, int price, String brandName, double score) { + return productRankSnapshotJpaRepository.save(ProductRankSnapshot.builder() + .rankingType(type) + .rankDate(rankDate) + .rankPosition(position) + .productId(productId) + .productName(name) + .price(price) + .brandName(brandName) + .totalViewCount(100L) + .totalLikeCount(50L) + .totalOrderLineCount(10L) + .totalOrderAmount(500000L) + .score(score) + .build()); + } + + @DisplayName("WEEKLY 지정 시 주간 랭킹을 점수 순으로 반환한다.") + @Test + void weeklyRanking_returnsSnapshotData() { + // arrange + LocalDate rankDate = LocalDate.of(2025, 4, 8); + Brand brand = saveBrand("나이키"); + Product p1 = saveProduct(brand.getId(), "에어맥스", 200000, 10); + Product p2 = saveProduct(brand.getId(), "조던", 300000, 5); + + saveSnapshot(RankingType.WEEKLY, rankDate, 1, p1.getId(), "에어맥스", 200000, "나이키", 700.0); + saveSnapshot(RankingType.WEEKLY, rankDate, 2, p2.getId(), "조던", 300000, "나이키", 500.0); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + RANKING_ENDPOINT + "?rankingType=WEEKLY&size=10", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List items = response.getBody().data(); + assertThat(items).hasSize(2); + assertThat(items.get(0).productId()).isEqualTo(p1.getId()); + assertThat(items.get(0).rank()).isEqualTo(1L); + assertThat(items.get(0).score()).isEqualTo(700.0); + assertThat(items.get(1).productId()).isEqualTo(p2.getId()); + assertThat(items.get(1).rank()).isEqualTo(2L); + } + + @DisplayName("MONTHLY 지정 시 월간 랭킹을 반환한다.") + @Test + void monthlyRanking_returnsSnapshotData() { + // arrange + LocalDate rankDate = LocalDate.of(2025, 4, 8); + Brand brand = saveBrand("아디다스"); + Product p1 = saveProduct(brand.getId(), "슈퍼스타", 120000, 10); + + saveSnapshot(RankingType.MONTHLY, rankDate, 1, p1.getId(), "슈퍼스타", 120000, "아디다스", 1500.0); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + RANKING_ENDPOINT + "?rankingType=MONTHLY&size=10", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List items = response.getBody().data(); + assertThat(items).hasSize(1); + assertThat(items.get(0).productName()).isEqualTo("슈퍼스타"); + assertThat(items.get(0).score()).isEqualTo(1500.0); + } + + @DisplayName("rankingType 미지정 시 기존 Redis 일간 랭킹을 반환한다. (하위 호환)") + @Test + void defaultRankingType_returnsDailyFromRedis() { + // arrange + LocalDate today = LocalDate.now(clock); + String key = RankingKeyGenerator.keyOf(today); + Brand brand = saveBrand("뉴발란스"); + Product product = saveProduct(brand.getId(), "990", 250000, 5); + + seedRankingScore(key, product.getId(), 400.0); + + // act + ResponseEntity>> response = + testRestTemplate.exchange( + RANKING_ENDPOINT + "?size=10", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List items = response.getBody().data(); + assertThat(items).hasSize(1); + assertThat(items.get(0).productId()).isEqualTo(product.getId()); + } + + @DisplayName("WEEKLY 지정 시 배치 결과가 없으면 빈 리스트를 반환한다.") + @Test + void weeklyRanking_returnsEmptyWhenNoData() { + // act + ResponseEntity>> response = + testRestTemplate.exchange( + RANKING_ENDPOINT + "?rankingType=WEEKLY", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data()).isEmpty(); + } + + @DisplayName("WEEKLY 조회 시 page/size 기반 페이지네이션이 동작한다.") + @Test + void weeklyRanking_paginationWorks() { + // arrange + LocalDate rankDate = LocalDate.of(2025, 4, 8); + Brand brand = saveBrand("푸마"); + Product p1 = saveProduct(brand.getId(), "상품1", 100000, 10); + Product p2 = saveProduct(brand.getId(), "상품2", 100000, 10); + Product p3 = saveProduct(brand.getId(), "상품3", 100000, 10); + + saveSnapshot(RankingType.WEEKLY, rankDate, 1, p1.getId(), "상품1", 100000, "푸마", 300.0); + saveSnapshot(RankingType.WEEKLY, rankDate, 2, p2.getId(), "상품2", 100000, "푸마", 200.0); + saveSnapshot(RankingType.WEEKLY, rankDate, 3, p3.getId(), "상품3", 100000, "푸마", 100.0); + + // act — page 2, size 2 → 3위만 반환 + ResponseEntity>> response = + testRestTemplate.exchange( + RANKING_ENDPOINT + "?rankingType=WEEKLY&page=2&size=2", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + List items = response.getBody().data(); + assertThat(items).hasSize(1); + assertThat(items.get(0).productId()).isEqualTo(p3.getId()); + assertThat(items.get(0).rank()).isEqualTo(3L); + } + } + @DisplayName("상품 상세 조회시, ") @Nested class GetProductWithRanking { From d36da68f9d3a8efaafcb9dacb3778ff391fff8ec Mon Sep 17 00:00:00 2001 From: hey-sion Date: Thu, 16 Apr 2026 19:17:08 +0900 Subject: [PATCH 7/7] =?UTF-8?q?test:=20=EB=B0=B0=EC=B9=98=20=EB=A1=A4?= =?UTF-8?q?=EB=A7=81=20=EC=9C=88=EB=8F=84=EC=9A=B0=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(WEEKLY=208=EC=9D=BC=20=EC=A0=84=20/=20MONTHLY=2031?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/RankingAggregationJobE2ETest.java | 408 ++++++++++++++++-- 1 file changed, 381 insertions(+), 27 deletions(-) diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java index cb20b0417..b533adb03 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java @@ -26,13 +26,17 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest @SpringBatchTest @TestPropertySource(properties = { "spring.batch.job.name=" + RankingAggregationJobConfig.JOB_NAME, - "spring.batch.job.enabled=false" + "spring.batch.job.enabled=false", + "ranking.weight.view=0.1", + "ranking.weight.like=0.2", + "ranking.weight.order=0.7" }) class RankingAggregationJobE2ETest { @@ -150,13 +154,45 @@ void weeklyAggregation_aggregates7Days() throws Exception { // assert assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); - List results = productRankSnapshotJpaRepository.findAll(); + List results = productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList(); + + assertThat(results).hasSize(3); + assertThat(results).allMatch(r -> r.getRankingType() == RankingType.WEEKLY); + assertThat(results).allMatch(r -> r.getRankDate().equals(END_DATE)); + + // rank 1: 상품B (product 2) — score = 50*0.1 + 100*0.2 + 1000000*0.7 = 700025.0 + ProductRankSnapshot rank1 = results.get(0); + assertAll( + () -> assertThat(rank1.getProductId()).isEqualTo(2L), + () -> assertThat(rank1.getRankPosition()).isEqualTo(1), + () -> assertThat(rank1.getTotalViewCount()).isEqualTo(50L), + () -> assertThat(rank1.getTotalLikeCount()).isEqualTo(100L), + () -> assertThat(rank1.getTotalOrderLineCount()).isEqualTo(20L), + () -> assertThat(rank1.getTotalOrderAmount()).isEqualTo(1000000L), + () -> assertThat(rank1.getScore()).isCloseTo(700025.0, within(0.01)) + ); + + // rank 2: 상품A (product 1) — score = 300*0.1 + 80*0.2 + 750000*0.7 = 525046.0 + ProductRankSnapshot rank2 = results.get(1); + assertAll( + () -> assertThat(rank2.getProductId()).isEqualTo(1L), + () -> assertThat(rank2.getRankPosition()).isEqualTo(2), + () -> assertThat(rank2.getTotalViewCount()).isEqualTo(300L), + () -> assertThat(rank2.getTotalLikeCount()).isEqualTo(80L), + () -> assertThat(rank2.getTotalOrderLineCount()).isEqualTo(15L), + () -> assertThat(rank2.getTotalOrderAmount()).isEqualTo(750000L), + () -> assertThat(rank2.getScore()).isCloseTo(525046.0, within(0.01)) + ); + + // rank 3: 상품C (product 3) — score = 10*0.1 + 5*0.2 + 30000*0.7 = 21002.0 + ProductRankSnapshot rank3 = results.get(2); assertAll( - () -> assertThat(results).isNotEmpty(), - () -> assertThat(results).allMatch(r -> r.getRankingType() == RankingType.WEEKLY), - () -> assertThat(results).allMatch(r -> r.getRankDate().equals(END_DATE)), - () -> assertThat(results.get(0).getRankPosition()).isEqualTo(1), - () -> assertThat(results.get(0).getScore()).isGreaterThan(results.get(1).getScore()) + () -> assertThat(rank3.getProductId()).isEqualTo(3L), + () -> assertThat(rank3.getRankPosition()).isEqualTo(3), + () -> assertThat(rank3.getScore()).isCloseTo(21002.0, within(0.01)) ); } @@ -178,35 +214,149 @@ void monthlyAggregation_aggregates30Days() throws Exception { assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); List results = productRankSnapshotJpaRepository.findAll(); + assertThat(results).hasSize(1); + + // 상품A: d1(3/30) view=100,like=50,orderLine=10,orderAmount=500000 + // d2(3/20) view=200,like=30,orderLine=5,orderAmount=250000 + // 합산: view=300, like=80, orderLine=15, orderAmount=750000 + // score = 300*0.1 + 80*0.2 + 750000*0.7 = 525046.0 + ProductRankSnapshot snapshot = results.get(0); + assertAll( + () -> assertThat(snapshot.getRankingType()).isEqualTo(RankingType.MONTHLY), + () -> assertThat(snapshot.getRankDate()).isEqualTo(END_DATE), + () -> assertThat(snapshot.getProductId()).isEqualTo(1L), + () -> assertThat(snapshot.getTotalViewCount()).isEqualTo(300L), + () -> assertThat(snapshot.getTotalLikeCount()).isEqualTo(80L), + () -> assertThat(snapshot.getTotalOrderLineCount()).isEqualTo(15L), + () -> assertThat(snapshot.getTotalOrderAmount()).isEqualTo(750000L), + () -> assertThat(snapshot.getScore()).isCloseTo(525046.0, within(0.01)) + ); + } + + @DisplayName("WEEKLY 타입: 7일 범위 밖(8일 전) 데이터는 집계에서 제외된다") + @Test + void weeklyAggregation_excludesDataOutsideWindow() throws Exception { + // arrange + // 상품B에 범위 내(4/14) 데이터와 범위 밖(4/7, 8일 전) 데이터를 모두 시딩한다. + // WEEKLY 집계 범위는 endDate-6 ~ endDate (4/8 ~ 4/14) 이므로, + // 4/7 데이터는 집계에서 제외되어야 한다. + seedTestDataForWindowBoundary(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList(); + + assertThat(results).hasSize(2); + + // 상품A (product 1): 범위 내 4/14(view=100,like=50,orderLine=10,orderAmount=500000) + // + 경계일 4/8(view=50,like=20,orderLine=5,orderAmount=200000) + // 합산: view=150, like=70, orderLine=15, orderAmount=700000 + // score = 150*0.1 + 70*0.2 + 700000*0.7 = 490029.0 + ProductRankSnapshot productA = results.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(productA.getTotalViewCount()).isEqualTo(150L), + () -> assertThat(productA.getTotalLikeCount()).isEqualTo(70L), + () -> assertThat(productA.getTotalOrderLineCount()).isEqualTo(15L), + () -> assertThat(productA.getTotalOrderAmount()).isEqualTo(700000L), + () -> assertThat(productA.getScore()).isCloseTo(490029.0, within(0.01)) + ); + + // 상품B (product 2): 범위 내(4/14: view=50, like=10, orderLine=3, orderAmount=100000)만 집계 + // 범위 밖(4/7: view=9999, like=9999, ...) 데이터는 제외 + // score = 50*0.1 + 10*0.2 + 100000*0.7 = 70007.0 + ProductRankSnapshot productB = results.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst() + .orElseThrow(); + assertAll( + () -> assertThat(productB.getTotalViewCount()).isEqualTo(50L), + () -> assertThat(productB.getTotalLikeCount()).isEqualTo(10L), + () -> assertThat(productB.getTotalOrderLineCount()).isEqualTo(3L), + () -> assertThat(productB.getTotalOrderAmount()).isEqualTo(100000L), + () -> assertThat(productB.getScore()).isCloseTo(70007.0, within(0.01)) + ); + } + + @DisplayName("MONTHLY 타입: 30일 범위 밖(31일 전) 데이터는 집계에서 제외된다") + @Test + void monthlyAggregation_excludesDataOutsideWindow() throws Exception { + // arrange + // 상품A에 범위 내(3/30) 데이터와 범위 밖(3/15, 31일 전) 데이터를 모두 시딩한다. + // MONTHLY 집계 범위는 endDate-29 ~ endDate (3/16 ~ 4/14) 이므로, + // 3/15 데이터는 집계에서 제외되어야 한다. + seedTestDataForMonthlyWindowBoundary(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "MONTHLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertThat(results).hasSize(1); + + // 상품A는 범위 내(3/30: view=100, like=50, order_amount=500000)와 + // 범위 밖(3/15: view=9999, like=9999, order_amount=99999999) 데이터가 모두 있지만, + // 집계 결과는 범위 내 데이터만으로 계산되어야 한다. + // 기대 score = 100 * 0.1 + 50 * 0.2 + 500000 * 0.7 = 350020.0 + ProductRankSnapshot snapshot = results.get(0); assertAll( - () -> assertThat(results).isNotEmpty(), - () -> assertThat(results).allMatch(r -> r.getRankingType() == RankingType.MONTHLY), - () -> assertThat(results).allMatch(r -> r.getRankDate().equals(END_DATE)) + () -> assertThat(snapshot.getRankingType()).isEqualTo(RankingType.MONTHLY), + () -> assertThat(snapshot.getRankDate()).isEqualTo(END_DATE), + () -> assertThat(snapshot.getProductId()).isEqualTo(1L), + () -> assertThat(snapshot.getRankPosition()).isEqualTo(1), + () -> assertThat(snapshot.getTotalViewCount()).isEqualTo(100L), + () -> assertThat(snapshot.getTotalLikeCount()).isEqualTo(50L), + () -> assertThat(snapshot.getTotalOrderLineCount()).isEqualTo(10L), + () -> assertThat(snapshot.getTotalOrderAmount()).isEqualTo(500000L), + () -> assertThat(snapshot.getScore()).isCloseTo(350020.0, within(0.01)) ); } - @DisplayName("멱등성: 같은 파라미터로 2회 실행해도 중복 데이터 없이 동일 결과를 반환한다") + @DisplayName("재집계: 같은 rankingType/endDate로 재실행 시 기존 스냅샷을 대체하고 중복이 발생하지 않는다") @Test - void idempotency_sameParamsTwice_noDuplicates() throws Exception { + void reAggregation_replacesExistingSnapshots_noDuplicates() throws Exception { // arrange seedTestData(); - // act - 1차 실행 - jobLauncherTestUtils.launchJob( + // act - 1차 집계 + var firstJobExecution = jobLauncherTestUtils.launchJob( new JobParametersBuilder() .addString("rankingType", "WEEKLY") .addLocalDate("endDate", END_DATE) .toJobParameters() ); + assertThat(firstJobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + List firstRunResults = productRankSnapshotJpaRepository.findAll() .stream() .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) .toList(); - // 2차 실행을 위해 배치 메타데이터 초기화 (같은 파라미터로 재실행 허용) + // 재집계를 위해 배치 메타데이터 초기화 (같은 파라미터로 재실행 허용) cleanUpBatchMetadata(); - // act - 2차 실행 + // act - 2차 집계 (동일 rankingType/endDate) var jobExecution = jobLauncherTestUtils.launchJob( new JobParametersBuilder() .addString("rankingType", "WEEKLY") @@ -215,12 +365,15 @@ void idempotency_sameParamsTwice_noDuplicates() throws Exception { ); // assert + List secondRunResults = productRankSnapshotJpaRepository.findAll() + .stream() + .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) + .toList(); + assertAll( () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), - () -> assertThat(productRankSnapshotJpaRepository.findAll() - .stream() - .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) - .toList()) + () -> assertThat(secondRunResults).hasSize(firstRunResults.size()), + () -> assertThat(secondRunResults) .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id") .containsExactlyElementsOf(firstRunResults) ); @@ -244,14 +397,68 @@ void excludesDeletedProducts() throws Exception { assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); List results = productRankSnapshotJpaRepository.findAll(); - assertThat(results).noneMatch(r -> r.getProductId().equals(99L)); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)), + () -> assertThat(results).noneMatch(r -> r.getProductId().equals(99L)) + ); } - @DisplayName("순위는 score 내림차순으로 정렬된다") + @DisplayName("비노출(HIDDEN) 상품은 랭킹에서 제외된다") @Test - void rankOrderedByScoreDescending() throws Exception { + void excludesHiddenProducts() throws Exception { // arrange - seedTestData(); + seedTestDataWithHiddenProduct(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)), + () -> assertThat(results).noneMatch(r -> r.getProductId().equals(98L)) + ); + } + + @DisplayName("삭제된 브랜드의 상품은 랭킹에서 제외된다") + @Test + void excludesProductsOfDeletedBrand() throws Exception { + // arrange + seedTestDataWithDeletedBrand(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString("rankingType", "WEEKLY") + .addLocalDate("endDate", END_DATE) + .toJobParameters() + ); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List results = productRankSnapshotJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results).anyMatch(r -> r.getProductId().equals(1L)), + () -> assertThat(results).noneMatch(r -> r.getProductId().equals(97L)) + ); + } + + @DisplayName("동점일 때 product_id 오름차순으로 순위가 매겨진다") + @Test + void tiedScore_orderedByProductIdAsc() throws Exception { + // arrange + seedTestDataForTiedScore(); // act var jobExecution = jobLauncherTestUtils.launchJob( @@ -268,9 +475,19 @@ void rankOrderedByScoreDescending() throws Exception { .stream() .sorted(Comparator.comparing(ProductRankSnapshot::getRankPosition)) .toList(); - for (int i = 0; i < results.size() - 1; i++) { - assertThat(results.get(i).getScore()).isGreaterThanOrEqualTo(results.get(i + 1).getScore()); - } + + assertThat(results).hasSize(3); + + // 상품A(id=1)와 상품B(id=2)는 동일 메트릭 → 동점 + // SQL: ORDER BY score DESC, pm.product_id ASC → product_id가 작은 쪽이 상위 + assertAll( + () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), + () -> assertThat(results.get(1).getProductId()).isEqualTo(2L), + () -> assertThat(results.get(0).getScore()).isEqualTo(results.get(1).getScore()), + () -> assertThat(results.get(0).getRankPosition()).isLessThan(results.get(1).getRankPosition()), + () -> assertThat(results.get(2).getProductId()).isEqualTo(3L), + () -> assertThat(results.get(2).getScore()).isLessThan(results.get(0).getScore()) + ); } private void seedTestData() { @@ -334,6 +551,143 @@ INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_cou }); } + private void seedTestDataForWindowBoundary() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '상품A', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (2, '상품B', 20000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + // WEEKLY endDate=4/14 → 범위: 4/8 ~ 4/14 + LocalDate insideWindow = END_DATE; // 4/14 (범위 내) + LocalDate boundaryInside = END_DATE.minusDays(6); // 4/8 (범위 내 경계) + LocalDate outsideWindow = END_DATE.minusDays(7); // 4/7 (범위 밖) + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :inside, 100, 50, 10, 500000), + (1, :boundary, 50, 20, 5, 200000), + (2, :inside, 50, 10, 3, 100000), + (2, :outside, 9999, 9999, 9999, 99999999) + """) + .setParameter("inside", insideWindow) + .setParameter("boundary", boundaryInside) + .setParameter("outside", outsideWindow) + .executeUpdate(); + }); + } + + private void seedTestDataForMonthlyWindowBoundary() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '상품A', 10000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + // MONTHLY endDate=4/14 → 범위: 3/16 ~ 4/14 + LocalDate insideWindow = END_DATE.minusDays(15); // 3/30 (범위 내) + LocalDate outsideWindow = END_DATE.minusDays(30); // 3/15 (범위 밖) + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :inside, 100, 50, 10, 500000), + (1, :outside, 9999, 9999, 9999, 99999999) + """) + .setParameter("inside", insideWindow) + .setParameter("outside", outsideWindow) + .executeUpdate(); + }); + } + + private void seedTestDataForTiedScore() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + // 상품A(id=1)와 상품B(id=2)는 동일 메트릭, 상품C(id=3)는 낮은 메트릭 + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '상품A', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (2, '상품B', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (3, '상품C', 10000, 1, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (2, :d1, 100, 50, 10, 500000), + (3, :d1, 10, 5, 1, 30000) + """) + .setParameter("d1", END_DATE) + .executeUpdate(); + }); + } + + private void seedTestDataWithHiddenProduct() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '브랜드A', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '정상상품', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (98, '비노출상품', 20000, 1, 'HIDDEN', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (98, :d1, 999, 999, 999, 9999999) + """) + .setParameter("d1", END_DATE) + .executeUpdate(); + }); + } + + private void seedTestDataWithDeletedBrand() { + transactionTemplate.executeWithoutResult(status -> { + entityManager.createNativeQuery(""" + INSERT INTO brands (id, name, created_at, updated_at) VALUES + (1, '정상브랜드', NOW(6), NOW(6)), + (2, '삭제된브랜드', NOW(6), NOW(6)) + """).executeUpdate(); + + // 브랜드 2 삭제 처리 + entityManager.createNativeQuery(""" + UPDATE brands SET deleted_at = NOW(6) WHERE id = 2 + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO products (id, name, price, brand_id, visibility, created_at, updated_at) VALUES + (1, '정상상품', 10000, 1, 'VISIBLE', NOW(6), NOW(6)), + (97, '삭제브랜드상품', 20000, 2, 'VISIBLE', NOW(6), NOW(6)) + """).executeUpdate(); + + entityManager.createNativeQuery(""" + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_line_count, order_amount) VALUES + (1, :d1, 100, 50, 10, 500000), + (97, :d1, 999, 999, 999, 9999999) + """) + .setParameter("d1", END_DATE) + .executeUpdate(); + }); + } + private void seedTestDataWithDeletedProduct() { transactionTemplate.executeWithoutResult(status -> { entityManager.createNativeQuery("""