From 215b61cbe833fcf5c44e3fd61a507d6d83e00400 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 01:23:47 +0900
Subject: [PATCH 01/14] =?UTF-8?q?feat:=20batch=20=EC=B2=98=EB=A6=AC=20?=
=?UTF-8?q?=EB=AA=A8=EB=93=87=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/commerce-api/build.gradle.kts | 3 --
.../src/main/resources/application.yml | 5 ---
apps/commerce-batch/build.gradle.kts | 22 ++++++++++
.../java/com/loopers/BatchApplication.java | 34 +++++++++++++++
.../src/main/resources/application.yml | 43 +++++++++++++++++++
settings.gradle.kts | 1 +
6 files changed, 100 insertions(+), 8 deletions(-)
create mode 100644 apps/commerce-batch/build.gradle.kts
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java
create mode 100644 apps/commerce-batch/src/main/resources/application.yml
diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 3ba4f7df5..f4d3b583a 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -24,9 +24,6 @@ dependencies {
implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
- // batch
- implementation("org.springframework.boot:spring-boot-starter-batch")
-
// querydsl
annotationProcessor("com.querydsl:querydsl-apt::jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 584ba6335..0856b8d81 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -24,11 +24,6 @@ spring:
- redis.yml
- logging.yml
- monitoring.yml
- batch:
- jdbc:
- initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 (임시: production 배포 전 EDA로 교체 예정)
- job:
- enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화
payment-gateway:
url: http://localhost:8082
diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts
new file mode 100644
index 000000000..1d691a669
--- /dev/null
+++ b/apps/commerce-batch/build.gradle.kts
@@ -0,0 +1,22 @@
+dependencies {
+ // add-ons
+ implementation(project(":modules:jpa"))
+ implementation(project(":modules:redis"))
+ implementation(project(":supports:jackson"))
+ implementation(project(":supports:logging"))
+ implementation(project(":supports:monitoring"))
+
+ // batch
+ implementation("org.springframework.boot:spring-boot-starter-batch")
+ testImplementation("org.springframework.batch:spring-batch-test")
+
+ // querydsl (필요시)
+ annotationProcessor("com.querydsl:querydsl-apt::jakarta")
+ annotationProcessor("jakarta.persistence:jakarta.persistence-api")
+ annotationProcessor("jakarta.annotation:jakarta.annotation-api")
+
+ // test-fixtures
+ testImplementation(testFixtures(project(":modules:jpa")))
+ testImplementation(testFixtures(project(":modules:redis")))
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java
new file mode 100644
index 000000000..76619b777
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java
@@ -0,0 +1,34 @@
+package com.loopers;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+/**
+ * Spring Batch 애플리케이션 메인 클래스.
+ *
+ * 대량 데이터 집계 및 배치 처리를 위한 독립 실행형 애플리케이션입니다.
+ *
+ *
+ * 실행 방법:
+ *
+ * java -jar commerce-batch.jar \
+ * --spring.batch.job.names=productMetricsAggregationJob \
+ * targetDate=20241215
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@SpringBootApplication(scanBasePackages = "com.loopers")
+@EnableJpaRepositories(basePackages = "com.loopers.infrastructure")
+@EntityScan(basePackages = "com.loopers.domain")
+public class BatchApplication {
+
+ public static void main(String[] args) {
+ System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args)));
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml
new file mode 100644
index 000000000..8c66d71dc
--- /dev/null
+++ b/apps/commerce-batch/src/main/resources/application.yml
@@ -0,0 +1,43 @@
+spring:
+ main:
+ web-application-type: none # 배치 전용이므로 웹 서버 불필요
+ application:
+ name: commerce-batch
+ profiles:
+ active: local
+ config:
+ import:
+ - jpa.yml
+ - redis.yml
+ - logging.yml
+ - monitoring.yml
+ batch:
+ jdbc:
+ initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성
+ job:
+ enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화
+
+---
+spring:
+ config:
+ activate:
+ on-profile: local, test
+
+---
+spring:
+ config:
+ activate:
+ on-profile: dev
+
+---
+spring:
+ config:
+ activate:
+ on-profile: qa
+
+---
+spring:
+ config:
+ activate:
+ on-profile: prd
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 161a1ba24..eeb4fbb90 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template"
include(
":apps:commerce-api",
+ ":apps:commerce-batch",
":apps:pg-simulator",
":apps:commerce-streamer",
":modules:jpa",
From 63769a14cbd4a8cc678be8fd57c31a78add1e0c5 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 01:38:00 +0900
Subject: [PATCH 02/14] =?UTF-8?q?feat:=20batch=20=EB=AA=A8=EB=93=88?=
=?UTF-8?q?=EC=97=90=20ProductMetrics=20=EB=8F=84=EB=A9=94=EC=9D=B8=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
---
.../domain/metrics/ProductMetrics.java | 134 +++++++++++
.../domain/metrics/ProductMetricsTest.java | 217 ++++++++++++++++++
2 files changed, 351 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
new file mode 100644
index 000000000..953aae115
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
@@ -0,0 +1,134 @@
+package com.loopers.domain.metrics;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 상품 메트릭 집계 엔티티.
+ *
+ * Spring Batch에서 집계 및 조회를 위한 메트릭 엔티티입니다.
+ *
+ *
+ * 도메인 분리 근거:
+ *
+ * - 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
+ * - Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
+ * - Spring Batch를 통한 대량 데이터 처리
+ *
+ *
+ *
+ * 모듈별 독립성:
+ *
+ * - commerce-batch 전용 엔티티 (독립적 진화 가능)
+ * - commerce-streamer와는 별도로 관리되어 모듈별 커스터마이징 가능
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "product_metrics")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductMetrics {
+
+ @Id
+ @Column(name = "product_id")
+ private Long productId;
+
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ @Column(name = "sales_count", nullable = false)
+ private Long salesCount;
+
+ @Column(name = "view_count", nullable = false)
+ private Long viewCount;
+
+ @Column(name = "version", nullable = false)
+ private Long version;
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ /**
+ * ProductMetrics 인스턴스를 생성합니다.
+ *
+ * @param productId 상품 ID
+ */
+ public ProductMetrics(Long productId) {
+ this.productId = productId;
+ this.likeCount = 0L;
+ this.salesCount = 0L;
+ this.viewCount = 0L;
+ this.version = 0L;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 좋아요 수를 증가시킵니다.
+ */
+ public void incrementLikeCount() {
+ this.likeCount++;
+ this.version++;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 좋아요 수를 감소시킵니다.
+ */
+ public void decrementLikeCount() {
+ if (this.likeCount > 0) {
+ this.likeCount--;
+ this.version++;
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ /**
+ * 판매량을 증가시킵니다.
+ *
+ * @param quantity 판매 수량
+ */
+ public void incrementSalesCount(Integer quantity) {
+ if (quantity != null && quantity > 0) {
+ this.salesCount += quantity;
+ this.version++;
+ this.updatedAt = LocalDateTime.now();
+ }
+ }
+
+ /**
+ * 상세 페이지 조회 수를 증가시킵니다.
+ */
+ public void incrementViewCount() {
+ this.viewCount++;
+ this.version++;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다.
+ *
+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다.
+ * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다.
+ *
+ *
+ * @param eventVersion 이벤트의 버전
+ * @return 업데이트해야 하면 true, 그렇지 않으면 false
+ */
+ public boolean shouldUpdate(Long eventVersion) {
+ if (eventVersion == null) {
+ // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성)
+ return true;
+ }
+ // 이벤트 버전이 메트릭 버전보다 크면 업데이트
+ return eventVersion > this.version;
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
new file mode 100644
index 000000000..133932ae4
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java
@@ -0,0 +1,217 @@
+package com.loopers.domain.metrics;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductMetrics 도메인 엔티티 테스트.
+ *
+ * commerce-batch 모듈의 ProductMetrics 엔티티에 대한 단위 테스트입니다.
+ *
+ */
+class ProductMetricsTest {
+
+ @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다")
+ @Test
+ void createsProductMetricsWithInitialValues() {
+ // arrange
+ Long productId = 1L;
+
+ // act
+ ProductMetrics metrics = new ProductMetrics(productId);
+
+ // assert
+ assertThat(metrics.getProductId()).isEqualTo(productId);
+ assertThat(metrics.getLikeCount()).isEqualTo(0L);
+ assertThat(metrics.getSalesCount()).isEqualTo(0L);
+ assertThat(metrics.getViewCount()).isEqualTo(0L);
+ assertThat(metrics.getVersion()).isEqualTo(0L);
+ assertThat(metrics.getUpdatedAt()).isNotNull();
+ }
+
+ @DisplayName("좋아요 수를 증가시킬 수 있다")
+ @Test
+ void canIncrementLikeCount() throws InterruptedException {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ Long initialLikeCount = metrics.getLikeCount();
+ Long initialVersion = metrics.getVersion();
+ LocalDateTime initialUpdatedAt = metrics.getUpdatedAt();
+
+ // act
+ Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연
+ metrics.incrementLikeCount();
+
+ // assert
+ assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1);
+ assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt);
+ }
+
+ @DisplayName("좋아요 수를 감소시킬 수 있다")
+ @Test
+ void canDecrementLikeCount() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount(); // 먼저 증가시킴
+ Long initialLikeCount = metrics.getLikeCount();
+ Long initialVersion = metrics.getVersion();
+
+ // act
+ metrics.decrementLikeCount();
+
+ // assert
+ assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1);
+ }
+
+ @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장)")
+ @Test
+ void preventsNegativeLikeCount_whenDecrementingFromZero() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ assertThat(metrics.getLikeCount()).isEqualTo(0L);
+ Long initialVersion = metrics.getVersion();
+
+ // act
+ metrics.decrementLikeCount();
+
+ // assert
+ assertThat(metrics.getLikeCount()).isEqualTo(0L);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음
+ }
+
+ @DisplayName("판매량을 증가시킬 수 있다")
+ @Test
+ void canIncrementSalesCount() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ Long initialSalesCount = metrics.getSalesCount();
+ Long initialVersion = metrics.getVersion();
+ Integer quantity = 5;
+
+ // act
+ metrics.incrementSalesCount(quantity);
+
+ // assert
+ assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1);
+ }
+
+ @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다")
+ @Test
+ void ignoresInvalidQuantity_whenIncrementingSalesCount() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ Long initialSalesCount = metrics.getSalesCount();
+ Long initialVersion = metrics.getVersion();
+
+ // act
+ metrics.incrementSalesCount(null);
+ metrics.incrementSalesCount(0);
+ metrics.incrementSalesCount(-1);
+
+ // assert
+ assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음
+ }
+
+ @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다")
+ @Test
+ void canIncrementViewCount() throws InterruptedException {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ Long initialViewCount = metrics.getViewCount();
+ Long initialVersion = metrics.getVersion();
+ LocalDateTime initialUpdatedAt = metrics.getUpdatedAt();
+
+ // act
+ Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연
+ metrics.incrementViewCount();
+
+ // assert
+ assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1);
+ assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1);
+ assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt);
+ }
+
+ @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다")
+ @Test
+ void canUpdateMultipleMetrics() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+
+ // act
+ metrics.incrementLikeCount();
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount(10);
+ metrics.incrementViewCount();
+ metrics.decrementLikeCount();
+
+ // assert
+ assertThat(metrics.getLikeCount()).isEqualTo(1L);
+ assertThat(metrics.getSalesCount()).isEqualTo(10L);
+ assertThat(metrics.getViewCount()).isEqualTo(1L);
+ assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨
+ }
+
+ @DisplayName("이벤트 버전이 메트릭 버전보다 크면 업데이트해야 한다고 판단한다")
+ @Test
+ void shouldUpdate_whenEventVersionIsGreater() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount(); // version = 1
+ Long eventVersion = 2L;
+
+ // act
+ boolean result = metrics.shouldUpdate(eventVersion);
+
+ // assert
+ assertThat(result).isTrue();
+ }
+
+ @DisplayName("이벤트 버전이 메트릭 버전보다 작거나 같으면 업데이트하지 않아야 한다고 판단한다")
+ @Test
+ void shouldNotUpdate_whenEventVersionIsLessOrEqual() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount(); // version = 1
+ metrics.incrementLikeCount(); // version = 2
+
+ // act & assert
+ assertThat(metrics.shouldUpdate(1L)).isFalse(); // 이벤트 버전이 더 작음
+ assertThat(metrics.shouldUpdate(2L)).isFalse(); // 이벤트 버전이 같음
+ }
+
+ @DisplayName("이벤트 버전이 null이면 업데이트해야 한다고 판단한다 (하위 호환성)")
+ @Test
+ void shouldUpdate_whenEventVersionIsNull() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount(); // version = 1
+
+ // act
+ boolean result = metrics.shouldUpdate(null);
+
+ // assert
+ assertThat(result).isTrue(); // 하위 호환성을 위해 null이면 업데이트
+ }
+
+ @DisplayName("초기 버전(0)인 메트릭은 모든 이벤트 버전에 대해 업데이트해야 한다고 판단한다")
+ @Test
+ void shouldUpdate_whenMetricsVersionIsZero() {
+ // arrange
+ ProductMetrics metrics = new ProductMetrics(1L);
+ assertThat(metrics.getVersion()).isEqualTo(0L);
+
+ // act & assert
+ assertThat(metrics.shouldUpdate(0L)).isFalse(); // 같으면 업데이트 안 함
+ assertThat(metrics.shouldUpdate(1L)).isTrue(); // 더 크면 업데이트
+ assertThat(metrics.shouldUpdate(100L)).isTrue(); // 더 크면 업데이트
+ }
+}
+
From c0fc73af58099726c5525dbd4bb4c68790790398 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 01:43:49 +0900
Subject: [PATCH 03/14] =?UTF-8?q?=20feat:=20ProudctMetrics=EC=9D=98=20Repo?=
=?UTF-8?q?sitory=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../metrics/ProductMetricsRepository.java | 86 +++++++++++++++++++
.../metrics/ProductMetricsJpaRepository.java | 58 +++++++++++++
.../metrics/ProductMetricsRepositoryImpl.java | 73 ++++++++++++++++
3 files changed, 217 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
new file mode 100644
index 000000000..aa831ba5a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -0,0 +1,86 @@
+package com.loopers.domain.metrics;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+/**
+ * ProductMetrics 엔티티에 대한 저장소 인터페이스.
+ *
+ * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다.
+ * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다.
+ *
+ *
+ * 도메인 분리 근거:
+ *
+ * - Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
+ * - Product 도메인의 핵심 비즈니스 로직과는 분리
+ *
+ *
+ *
+ * 배치 전용 메서드:
+ *
+ * - Spring Batch에서 날짜 기반 조회를 위한 메서드 포함
+ * - 대량 데이터 처리를 위한 페이징 조회 지원
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface ProductMetricsRepository {
+
+ /**
+ * 상품 메트릭을 저장합니다.
+ *
+ * @param productMetrics 저장할 상품 메트릭
+ * @return 저장된 상품 메트릭
+ */
+ ProductMetrics save(ProductMetrics productMetrics);
+
+ /**
+ * 상품 ID로 메트릭을 조회합니다.
+ *
+ * @param productId 상품 ID
+ * @return 조회된 메트릭을 담은 Optional
+ */
+ Optional findByProductId(Long productId);
+
+ /**
+ * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다.
+ *
+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다.
+ * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다.
+ *
+ *
+ * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00)
+ * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999)
+ * @param pageable 페이징 정보
+ * @return 조회된 메트릭 페이지
+ */
+ Page findByUpdatedAtBetween(
+ LocalDateTime startDateTime,
+ LocalDateTime endDateTime,
+ Pageable pageable
+ );
+
+ /**
+ * Spring Batch의 RepositoryItemReader에서 사용하기 위한 JPA Repository를 반환합니다.
+ *
+ * RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로,
+ * 기술적 제약으로 인해 JPA Repository에 대한 접근을 제공합니다.
+ *
+ *
+ * 주의: 이 메서드는 Spring Batch의 기술적 요구사항으로 인해 제공됩니다.
+ * 일반적인 비즈니스 로직에서는 이 메서드를 사용하지 않고,
+ * 위의 도메인 메서드들을 사용해야 합니다.
+ *
+ *
+ * @return PagingAndSortingRepository를 구현한 JPA Repository
+ */
+ @SuppressWarnings("rawtypes")
+ org.springframework.data.repository.PagingAndSortingRepository getJpaRepository();
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
new file mode 100644
index 000000000..e76dd736f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
@@ -0,0 +1,58 @@
+package com.loopers.infrastructure.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+/**
+ * ProductMetrics JPA Repository.
+ *
+ * 상품 메트릭 집계 데이터를 관리합니다.
+ * commerce-batch 전용 Repository입니다.
+ *
+ *
+ * 모듈별 독립성:
+ *
+ * - commerce-batch의 필요에 맞게 커스터마이징된 Repository
+ * - Spring Batch에서 날짜 기반 조회에 최적화
+ *
+ *
+ */
+public interface ProductMetricsJpaRepository extends JpaRepository {
+
+ /**
+ * 상품 ID로 메트릭을 조회합니다.
+ *
+ * @param productId 상품 ID
+ * @return 조회된 메트릭을 담은 Optional
+ */
+ Optional findByProductId(Long productId);
+
+ /**
+ * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다.
+ *
+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다.
+ * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다.
+ *
+ *
+ * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00)
+ * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999)
+ * @param pageable 페이징 정보
+ * @return 조회된 메트릭 페이지
+ */
+ @Query("SELECT pm FROM ProductMetrics pm " +
+ "WHERE pm.updatedAt >= :startDateTime AND pm.updatedAt < :endDateTime " +
+ "ORDER BY pm.productId")
+ Page findByUpdatedAtBetween(
+ @Param("startDateTime") LocalDateTime startDateTime,
+ @Param("endDateTime") LocalDateTime endDateTime,
+ Pageable pageable
+ );
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
new file mode 100644
index 000000000..70b775e30
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -0,0 +1,73 @@
+package com.loopers.infrastructure.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+/**
+ * ProductMetricsRepository의 JPA 구현체.
+ *
+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의
+ * 영속성 작업을 처리합니다.
+ *
+ *
+ * 배치 전용 구현:
+ *
+ * - Spring Batch에서 날짜 기반 조회에 최적화
+ * - 대량 데이터 처리를 위한 페이징 조회 지원
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+
+ private final ProductMetricsJpaRepository productMetricsJpaRepository;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public ProductMetrics save(ProductMetrics productMetrics) {
+ return productMetricsJpaRepository.save(productMetrics);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Optional findByProductId(Long productId) {
+ return productMetricsJpaRepository.findByProductId(productId);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Page findByUpdatedAtBetween(
+ LocalDateTime startDateTime,
+ LocalDateTime endDateTime,
+ Pageable pageable
+ ) {
+ return productMetricsJpaRepository.findByUpdatedAtBetween(startDateTime, endDateTime, pageable);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @SuppressWarnings("rawtypes")
+ public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() {
+ return productMetricsJpaRepository;
+ }
+}
+
From 8c752576824027bc99f42887e4269456df3b2b9e Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 01:49:16 +0900
Subject: [PATCH 04/14] =?UTF-8?q?test:=20Product=20Metrics=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=EC=97=90=20=EB=8C=80=ED=95=9C=20?=
=?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ProductMetricsItemProcessorTest.java | 87 ++++++++++++
.../metrics/ProductMetricsItemReaderTest.java | 134 ++++++++++++++++++
.../metrics/ProductMetricsItemWriterTest.java | 118 +++++++++++++++
3 files changed, 339 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java
new file mode 100644
index 000000000..23869009a
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java
@@ -0,0 +1,87 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductMetricsItemProcessor 테스트.
+ */
+class ProductMetricsItemProcessorTest {
+
+ private final ProductMetricsItemProcessor processor = new ProductMetricsItemProcessor();
+
+ @DisplayName("ProductMetrics를 그대로 전달한다 (pass-through)")
+ @Test
+ void processesItem_andReturnsSameItem() throws Exception {
+ // arrange
+ ProductMetrics item = new ProductMetrics(1L);
+ item.incrementLikeCount();
+ item.incrementSalesCount(10);
+ item.incrementViewCount();
+
+ // act
+ ProductMetrics result = processor.process(item);
+
+ // assert
+ assertThat(result).isSameAs(item); // 동일한 객체 반환
+ assertThat(result.getProductId()).isEqualTo(1L);
+ assertThat(result.getLikeCount()).isEqualTo(1L);
+ assertThat(result.getSalesCount()).isEqualTo(10L);
+ assertThat(result.getViewCount()).isEqualTo(1L);
+ }
+
+ @DisplayName("null이 아닌 모든 ProductMetrics를 처리한다")
+ @Test
+ void processesNonNullItem() throws Exception {
+ // arrange
+ ProductMetrics item = new ProductMetrics(100L);
+
+ // act
+ ProductMetrics result = processor.process(item);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result).isSameAs(item);
+ }
+
+ @DisplayName("여러 번 처리해도 동일한 결과를 반환한다")
+ @Test
+ void processesItemMultipleTimes_returnsSameResult() throws Exception {
+ // arrange
+ ProductMetrics item = new ProductMetrics(1L);
+ item.incrementLikeCount();
+
+ // act
+ ProductMetrics result1 = processor.process(item);
+ ProductMetrics result2 = processor.process(item);
+ ProductMetrics result3 = processor.process(item);
+
+ // assert
+ assertThat(result1).isSameAs(item);
+ assertThat(result2).isSameAs(item);
+ assertThat(result3).isSameAs(item);
+ }
+
+ @DisplayName("초기값을 가진 ProductMetrics도 처리한다")
+ @Test
+ void processesItemWithInitialValues() throws Exception {
+ // arrange
+ ProductMetrics item = new ProductMetrics(1L);
+ // 초기값: 모든 카운트가 0
+
+ // act
+ ProductMetrics result = processor.process(item);
+
+ // assert
+ assertThat(result).isSameAs(item);
+ assertThat(result.getLikeCount()).isEqualTo(0L);
+ assertThat(result.getSalesCount()).isEqualTo(0L);
+ assertThat(result.getViewCount()).isEqualTo(0L);
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java
new file mode 100644
index 000000000..4a3a75f93
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java
@@ -0,0 +1,134 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.batch.item.data.RepositoryItemReader;
+import org.springframework.data.repository.PagingAndSortingRepository;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * ProductMetricsItemReader 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductMetricsItemReaderTest {
+
+ @Mock
+ private ProductMetricsRepository productMetricsRepository;
+
+ @Mock
+ private PagingAndSortingRepository jpaRepository;
+
+ @DisplayName("올바른 날짜 형식으로 Reader를 생성할 수 있다")
+ @Test
+ void createsReader_withValidDate() {
+ // arrange
+ String targetDate = "20241215";
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ assertThat(itemReader.getName()).isEqualTo("productMetricsReader");
+ }
+
+ @DisplayName("날짜 파라미터가 null이면 오늘 날짜를 사용하여 Reader를 생성한다")
+ @Test
+ void createsReader_withNullDate_usesToday() {
+ // arrange
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader(null);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ }
+
+ @DisplayName("날짜 파라미터가 빈 문자열이면 오늘 날짜를 사용하여 Reader를 생성한다")
+ @Test
+ void createsReader_withEmptyDate_usesToday() {
+ // arrange
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader("");
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ }
+
+ @DisplayName("잘못된 날짜 형식이면 오늘 날짜를 사용하여 Reader를 생성한다")
+ @Test
+ void createsReader_withInvalidDate_usesToday() {
+ // arrange
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader("invalid-date");
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ }
+
+ @DisplayName("날짜 파라미터를 올바르게 파싱하여 날짜 범위를 설정한다")
+ @Test
+ void parsesDateCorrectly_andSetsDateTimeRange() {
+ // arrange
+ String targetDate = "20241215";
+ LocalDate expectedDate = LocalDate.of(2024, 12, 15);
+ LocalDateTime expectedStart = expectedDate.atStartOfDay();
+ LocalDateTime expectedEnd = expectedDate.atTime(LocalTime.MAX);
+
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ // 날짜 파싱이 올바르게 되었는지 확인 (Reader 내부에서 사용되므로 간접적으로 검증)
+ // 실제 날짜 범위는 Repository 호출 시 사용되므로, Reader가 정상 생성되었으면 성공
+ }
+
+ @DisplayName("JPA Repository를 통해 Reader를 생성한다")
+ @Test
+ void createsReader_usingJpaRepository() {
+ // arrange
+ String targetDate = "20241215";
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ // getJpaRepository()가 호출되었는지 확인
+ // (실제로는 RepositoryItemReader 내부에서 사용되므로 간접적으로 검증)
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java
new file mode 100644
index 000000000..d0613096e
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java
@@ -0,0 +1,118 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.item.Chunk;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * ProductMetricsItemWriter 테스트.
+ */
+class ProductMetricsItemWriterTest {
+
+ private final ProductMetricsItemWriter writer = new ProductMetricsItemWriter();
+
+ @DisplayName("Chunk를 정상적으로 처리할 수 있다")
+ @Test
+ void writesChunk_successfully() throws Exception {
+ // arrange
+ List items = createProductMetricsList(3);
+ Chunk chunk = new Chunk<>(items);
+
+ // act & assert
+ assertThatCode(() -> writer.write(chunk))
+ .doesNotThrowAnyException();
+ }
+
+ @DisplayName("빈 Chunk도 처리할 수 있다")
+ @Test
+ void writesEmptyChunk_successfully() throws Exception {
+ // arrange
+ Chunk chunk = new Chunk<>(new ArrayList<>());
+
+ // act & assert
+ assertThatCode(() -> writer.write(chunk))
+ .doesNotThrowAnyException();
+ }
+
+ @DisplayName("큰 Chunk도 처리할 수 있다")
+ @Test
+ void writesLargeChunk_successfully() throws Exception {
+ // arrange
+ List items = createProductMetricsList(100); // Chunk 크기와 동일
+ Chunk chunk = new Chunk<>(items);
+
+ // act & assert
+ assertThatCode(() -> writer.write(chunk))
+ .doesNotThrowAnyException();
+ }
+
+ @DisplayName("다양한 메트릭 값을 가진 Chunk를 처리할 수 있다")
+ @Test
+ void writesChunk_withVariousMetrics() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ ProductMetrics metrics1 = new ProductMetrics(1L);
+ metrics1.incrementLikeCount();
+ items.add(metrics1);
+
+ ProductMetrics metrics2 = new ProductMetrics(2L);
+ metrics2.incrementSalesCount(100);
+ items.add(metrics2);
+
+ ProductMetrics metrics3 = new ProductMetrics(3L);
+ metrics3.incrementViewCount();
+ metrics3.incrementViewCount();
+ items.add(metrics3);
+
+ Chunk chunk = new Chunk<>(items);
+
+ // act & assert
+ assertThatCode(() -> writer.write(chunk))
+ .doesNotThrowAnyException();
+ }
+
+ @DisplayName("Chunk의 모든 항목을 처리한다")
+ @Test
+ void writesChunk_processesAllItems() throws Exception {
+ // arrange
+ int itemCount = 10;
+ List items = createProductMetricsList(itemCount);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ // 현재는 로깅만 수행하므로 예외가 발생하지 않으면 성공
+ // 향후 Materialized View 저장 로직 추가 시 추가 검증 필요
+ assertThatCode(() -> writer.write(chunk))
+ .doesNotThrowAnyException();
+ }
+
+ /**
+ * 테스트용 ProductMetrics 리스트를 생성합니다.
+ *
+ * @param count 생성할 항목 수
+ * @return ProductMetrics 리스트
+ */
+ private List createProductMetricsList(int count) {
+ List items = new ArrayList<>();
+ for (long i = 1; i <= count; i++) {
+ ProductMetrics metrics = new ProductMetrics(i);
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount((int) i);
+ metrics.incrementViewCount();
+ items.add(metrics);
+ }
+ return items;
+ }
+}
+
From 9ee7c5bccb479ad44f421b2cc60e664d448f50a4 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 01:49:40 +0900
Subject: [PATCH 05/14] =?UTF-8?q?feat:=20ProductMetrics=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../metrics/ProductMetricsItemProcessor.java | 45 ++++++
.../metrics/ProductMetricsItemReader.java | 111 +++++++++++++
.../metrics/ProductMetricsItemWriter.java | 58 +++++++
.../metrics/ProductMetricsJobConfig.java | 148 ++++++++++++++++++
4 files changed, 362 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java
new file mode 100644
index 000000000..7d23b370a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java
@@ -0,0 +1,45 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.stereotype.Component;
+
+/**
+ * ProductMetrics를 처리하는 Spring Batch ItemProcessor.
+ *
+ * 현재는 데이터를 그대로 전달하지만, 향후 집계 로직을 추가할 수 있습니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Reader와 Writer 사이의 변환/필터링 로직을 위한 확장 포인트 제공
+ * - 향후 주간/월간 집계를 위한 데이터 변환 로직 추가 가능
+ * - 비즈니스 로직 검증 및 필터링 수행 가능
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+public class ProductMetricsItemProcessor implements ItemProcessor {
+
+ /**
+ * ProductMetrics를 처리합니다.
+ *
+ * 현재는 데이터를 그대로 전달하지만, 필요시 변환/필터링 로직을 추가할 수 있습니다.
+ *
+ *
+ * @param item 처리할 ProductMetrics
+ * @return 처리된 ProductMetrics (null 반환 시 해당 항목은 Writer로 전달되지 않음)
+ */
+ @Override
+ public ProductMetrics process(ProductMetrics item) throws Exception {
+ // 현재는 데이터를 그대로 전달
+ // 향후 집계 로직이나 데이터 변환이 필요하면 여기에 추가
+ return item;
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java
new file mode 100644
index 000000000..b7f420b87
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java
@@ -0,0 +1,111 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.data.RepositoryItemReader;
+import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.repository.PagingAndSortingRepository;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * ProductMetrics를 읽기 위한 Spring Batch ItemReader Factory.
+ *
+ * Chunk-Oriented Processing을 위해 JPA Repository 기반 Reader를 생성합니다.
+ * 특정 날짜의 product_metrics 데이터를 페이징하여 읽습니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
+ * - 날짜 파라미터를 받아 해당 날짜의 데이터만 조회
+ * - product_id 기준 정렬로 일관된 읽기 순서 보장
+ *
+ *
+ *
+ * DIP 준수:
+ *
+ * - 도메인 레이어의 ProductMetricsRepository 인터페이스를 사용
+ * - Spring Batch의 기술적 제약으로 인해 getJpaRepository()를 통해 JPA Repository 접근
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductMetricsItemReader {
+
+ private final ProductMetricsRepository productMetricsRepository;
+
+ /**
+ * ProductMetrics를 읽는 ItemReader를 생성합니다.
+ *
+ * Job 파라미터에서 날짜를 받아 해당 날짜의 데이터만 조회합니다.
+ *
+ *
+ * @param targetDate 조회할 날짜 (yyyyMMdd 형식)
+ * @return RepositoryItemReader 인스턴스
+ */
+ public RepositoryItemReader createReader(String targetDate) {
+ // 날짜 파라미터 파싱
+ LocalDate date = parseDate(targetDate);
+ LocalDateTime startDateTime = date.atStartOfDay();
+ LocalDateTime endDateTime = date.atTime(LocalTime.MAX);
+
+ log.info("ProductMetrics Reader 초기화: targetDate={}, startDateTime={}, endDateTime={}",
+ date, startDateTime, endDateTime);
+
+ // 정렬 기준 설정 (product_id 기준 오름차순)
+ Map sorts = new HashMap<>();
+ sorts.put("productId", Sort.Direction.ASC);
+
+ // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로
+ // 기술적 제약으로 인해 getJpaRepository()를 통해 접근
+ PagingAndSortingRepository jpaRepository =
+ productMetricsRepository.getJpaRepository();
+
+ return new RepositoryItemReaderBuilder()
+ .name("productMetricsReader")
+ .repository(jpaRepository)
+ .methodName("findByUpdatedAtBetween")
+ .arguments(startDateTime, endDateTime)
+ .pageSize(100) // Chunk 크기와 동일하게 설정
+ .sorts(sorts)
+ .build();
+ }
+
+ /**
+ * 날짜 문자열을 LocalDate로 파싱합니다.
+ *
+ * yyyyMMdd 형식의 문자열을 파싱하며, 파싱 실패 시 오늘 날짜를 반환합니다.
+ *
+ *
+ * @param dateStr 날짜 문자열 (yyyyMMdd 형식)
+ * @return 파싱된 날짜
+ */
+ private LocalDate parseDate(String dateStr) {
+ if (dateStr == null || dateStr.isEmpty()) {
+ log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다.");
+ return LocalDate.now();
+ }
+
+ try {
+ return LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
+ } catch (Exception e) {
+ log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e);
+ return LocalDate.now();
+ }
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java
new file mode 100644
index 000000000..89364f52e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java
@@ -0,0 +1,58 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * ProductMetrics를 처리하는 Spring Batch ItemWriter.
+ *
+ * 현재는 로깅만 수행하지만, 향후 Materialized View에 저장하는 로직을 추가할 수 있습니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Chunk 단위로 데이터를 처리하여 대량 데이터 처리 성능 최적화
+ * - 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정
+ * - 트랜잭션 단위는 Chunk 단위로 관리
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+public class ProductMetricsItemWriter implements ItemWriter {
+
+ /**
+ * ProductMetrics Chunk를 처리합니다.
+ *
+ * 현재는 로깅만 수행하며, 향후 Materialized View에 저장하는 로직을 추가할 예정입니다.
+ *
+ *
+ * @param chunk 처리할 ProductMetrics Chunk
+ * @throws Exception 처리 중 오류 발생 시
+ */
+ @Override
+ public void write(Chunk extends ProductMetrics> chunk) throws Exception {
+ List extends ProductMetrics> items = chunk.getItems();
+
+ log.info("ProductMetrics Chunk 처리 시작: itemCount={}", items.size());
+
+ // 현재는 로깅만 수행
+ // 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정
+ for (ProductMetrics item : items) {
+ log.debug("ProductMetrics 처리: productId={}, likeCount={}, salesCount={}, viewCount={}, updatedAt={}",
+ item.getProductId(), item.getLikeCount(), item.getSalesCount(),
+ item.getViewCount(), item.getUpdatedAt());
+ }
+
+ log.info("ProductMetrics Chunk 처리 완료: itemCount={}", items.size());
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java
new file mode 100644
index 000000000..1c874b3b7
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java
@@ -0,0 +1,148 @@
+package com.loopers.infrastructure.batch.metrics;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.batch.item.ItemReader;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+/**
+ * ProductMetrics 집계를 위한 Spring Batch Job Configuration.
+ *
+ * Chunk-Oriented Processing 방식을 사용하여 대량의 product_metrics 데이터를 처리합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
+ * - Job 파라미터 기반 실행: 날짜를 파라미터로 받아 특정 날짜의 데이터만 처리
+ * - 확장성: 향후 주간/월간 집계를 위한 구조 준비
+ * - 재시작 가능: 실패 시 이전 Chunk부터 재시작 가능
+ *
+ *
+ *
+ * Chunk 크기 선택 근거:
+ *
+ * - 100개: 메모리 사용량과 성능의 균형
+ * - 너무 작으면: 트랜잭션 오버헤드 증가
+ * - 너무 크면: 메모리 사용량 증가 및 롤백 범위 확대
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class ProductMetricsJobConfig {
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final ProductMetricsItemReader productMetricsItemReader;
+ private final ProductMetricsItemProcessor productMetricsItemProcessor;
+ private final ProductMetricsItemWriter productMetricsItemWriter;
+
+ /**
+ * ProductMetrics 집계 Job을 생성합니다.
+ *
+ * Job 파라미터:
+ *
+ * - targetDate: 처리할 날짜 (yyyyMMdd 형식, 예: "20241215")
+ *
+ *
+ *
+ * 실행 예시:
+ *
+ * java -jar commerce-batch.jar --spring.batch.job.names=productMetricsAggregationJob targetDate=20241215
+ *
+ *
+ *
+ * @return ProductMetrics 집계 Job
+ */
+ @Bean
+ public Job productMetricsAggregationJob(Step productMetricsAggregationStep) {
+ return new JobBuilder("productMetricsAggregationJob", jobRepository)
+ .start(productMetricsAggregationStep)
+ .build();
+ }
+
+ /**
+ * ProductMetrics 집계 Step을 생성합니다.
+ *
+ * Chunk-Oriented Processing을 사용하여:
+ *
+ * - Reader: 특정 날짜의 product_metrics를 페이징하여 읽기
+ * - Processor: 데이터 변환/필터링 (현재는 pass-through)
+ * - Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)
+ *
+ *
+ *
+ * @param productMetricsReader ProductMetrics Reader (StepScope Bean)
+ * @param productMetricsProcessor ProductMetrics Processor
+ * @param productMetricsWriter ProductMetrics Writer
+ * @return ProductMetrics 집계 Step
+ */
+ @Bean
+ public Step productMetricsAggregationStep(
+ ItemReader productMetricsReader,
+ ItemProcessor productMetricsProcessor,
+ ItemWriter productMetricsWriter
+ ) {
+ return new StepBuilder("productMetricsAggregationStep", jobRepository)
+ .chunk(100, transactionManager) // Chunk 크기: 100
+ .reader(productMetricsReader) // StepScope Bean은 Step 실행 시점에 자동 주입됨
+ .processor(productMetricsProcessor)
+ .writer(productMetricsWriter)
+ .build();
+ }
+
+ /**
+ * ProductMetrics Reader를 생성합니다.
+ *
+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다.
+ *
+ *
+ * @param targetDate 조회할 날짜 (Job 파라미터에서 주입)
+ * @return ProductMetrics Reader (StepScope로 선언되어 Step 실행 시 생성)
+ */
+ @Bean
+ @StepScope
+ public ItemReader productMetricsReader(
+ @Value("#{jobParameters['targetDate']}") String targetDate
+ ) {
+ return productMetricsItemReader.createReader(targetDate);
+ }
+
+ /**
+ * ProductMetrics Processor를 주입받습니다.
+ *
+ * @return ProductMetrics Processor
+ */
+ @Bean
+ public ItemProcessor productMetricsProcessor() {
+ return productMetricsItemProcessor;
+ }
+
+ /**
+ * ProductMetrics Writer를 주입받습니다.
+ *
+ * @return ProductMetrics Writer
+ */
+ @Bean
+ public ItemWriter productMetricsWriter() {
+ return productMetricsItemWriter;
+ }
+}
+
From 6ff36edb6b30f9bcc05257dccc02d442b2510ded Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:08:55 +0900
Subject: [PATCH 06/14] =?UTF-8?q?test:=20Product=20Rank=EC=97=90=20?=
=?UTF-8?q?=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?=
=?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loopers/domain/rank/ProductRankTest.java | 235 ++++++++++++++++++
1 file changed, 235 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java
new file mode 100644
index 000000000..72d0c592f
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java
@@ -0,0 +1,235 @@
+package com.loopers.domain.rank;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductRank 도메인 엔티티 테스트.
+ *
+ * commerce-batch 모듈의 ProductRank 엔티티에 대한 단위 테스트입니다.
+ *
+ */
+class ProductRankTest {
+
+ @DisplayName("ProductRank는 모든 필수 정보로 생성된다")
+ @Test
+ void createsProductRankWithAllFields() {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9); // 월요일
+ Long productId = 1L;
+ Integer rank = 1;
+ Long likeCount = 100L;
+ Long salesCount = 500L;
+ Long viewCount = 1000L;
+
+ // act
+ ProductRank productRank = new ProductRank(
+ periodType,
+ periodStartDate,
+ productId,
+ rank,
+ likeCount,
+ salesCount,
+ viewCount
+ );
+
+ // assert
+ assertThat(productRank.getPeriodType()).isEqualTo(periodType);
+ assertThat(productRank.getPeriodStartDate()).isEqualTo(periodStartDate);
+ assertThat(productRank.getProductId()).isEqualTo(productId);
+ assertThat(productRank.getRank()).isEqualTo(rank);
+ assertThat(productRank.getLikeCount()).isEqualTo(likeCount);
+ assertThat(productRank.getSalesCount()).isEqualTo(salesCount);
+ assertThat(productRank.getViewCount()).isEqualTo(viewCount);
+ assertThat(productRank.getCreatedAt()).isNotNull();
+ assertThat(productRank.getUpdatedAt()).isNotNull();
+ }
+
+ @DisplayName("ProductRank 생성 시 createdAt과 updatedAt이 현재 시간으로 설정된다")
+ @Test
+ void setsCreatedAtAndUpdatedAtOnCreation() throws InterruptedException {
+ // arrange
+ LocalDateTime beforeCreation = LocalDateTime.now();
+ Thread.sleep(1);
+
+ // act
+ ProductRank productRank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ LocalDate.of(2024, 12, 9),
+ 1L,
+ 1,
+ 100L,
+ 500L,
+ 1000L
+ );
+
+ Thread.sleep(1);
+ LocalDateTime afterCreation = LocalDateTime.now();
+
+ // assert
+ assertThat(productRank.getCreatedAt())
+ .isAfter(beforeCreation)
+ .isBefore(afterCreation);
+ assertThat(productRank.getUpdatedAt())
+ .isAfter(beforeCreation)
+ .isBefore(afterCreation);
+ }
+
+ @DisplayName("주간 랭킹을 생성할 수 있다")
+ @Test
+ void createsWeeklyRank() {
+ // arrange
+ LocalDate weekStart = LocalDate.of(2024, 12, 9); // 월요일
+
+ // act
+ ProductRank weeklyRank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ weekStart,
+ 1L,
+ 1,
+ 100L,
+ 500L,
+ 1000L
+ );
+
+ // assert
+ assertThat(weeklyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY);
+ assertThat(weeklyRank.getPeriodStartDate()).isEqualTo(weekStart);
+ }
+
+ @DisplayName("월간 랭킹을 생성할 수 있다")
+ @Test
+ void createsMonthlyRank() {
+ // arrange
+ LocalDate monthStart = LocalDate.of(2024, 12, 1); // 월의 1일
+
+ // act
+ ProductRank monthlyRank = new ProductRank(
+ ProductRank.PeriodType.MONTHLY,
+ monthStart,
+ 1L,
+ 1,
+ 100L,
+ 500L,
+ 1000L
+ );
+
+ // assert
+ assertThat(monthlyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY);
+ assertThat(monthlyRank.getPeriodStartDate()).isEqualTo(monthStart);
+ }
+
+ @DisplayName("랭킹 정보를 업데이트할 수 있다")
+ @Test
+ void canUpdateRank() throws InterruptedException {
+ // arrange
+ ProductRank productRank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ LocalDate.of(2024, 12, 9),
+ 1L,
+ 1,
+ 100L,
+ 500L,
+ 1000L
+ );
+ Integer newRank = 2;
+ Long newLikeCount = 200L;
+ Long newSalesCount = 600L;
+ Long newViewCount = 1100L;
+ LocalDateTime initialUpdatedAt = productRank.getUpdatedAt();
+
+ // act
+ Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연
+ productRank.updateRank(newRank, newLikeCount, newSalesCount, newViewCount);
+
+ // assert
+ assertThat(productRank.getRank()).isEqualTo(newRank);
+ assertThat(productRank.getLikeCount()).isEqualTo(newLikeCount);
+ assertThat(productRank.getSalesCount()).isEqualTo(newSalesCount);
+ assertThat(productRank.getViewCount()).isEqualTo(newViewCount);
+ assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt);
+ }
+
+ @DisplayName("랭킹 업데이트 시 updatedAt이 갱신된다")
+ @Test
+ void updatesUpdatedAtWhenRankIsUpdated() throws InterruptedException {
+ // arrange
+ ProductRank productRank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ LocalDate.of(2024, 12, 9),
+ 1L,
+ 1,
+ 100L,
+ 500L,
+ 1000L
+ );
+ LocalDateTime initialUpdatedAt = productRank.getUpdatedAt();
+
+ // act
+ Thread.sleep(1);
+ productRank.updateRank(2, 200L, 600L, 1100L);
+
+ // assert
+ assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt);
+ }
+
+ @DisplayName("PeriodType enum이 올바르게 정의되어 있다")
+ @Test
+ void periodTypeEnumIsCorrectlyDefined() {
+ // assert
+ assertThat(ProductRank.PeriodType.WEEKLY).isNotNull();
+ assertThat(ProductRank.PeriodType.MONTHLY).isNotNull();
+ assertThat(ProductRank.PeriodType.values()).hasSize(2);
+ }
+
+ @DisplayName("TOP 100 랭킹을 생성할 수 있다")
+ @Test
+ void createsTop100Rank() {
+ // arrange
+ Integer topRank = 100;
+
+ // act
+ ProductRank top100Rank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ LocalDate.of(2024, 12, 9),
+ 100L,
+ topRank,
+ 1L,
+ 1L,
+ 1L
+ );
+
+ // assert
+ assertThat(top100Rank.getRank()).isEqualTo(topRank);
+ assertThat(top100Rank.getRank()).isLessThanOrEqualTo(100);
+ }
+
+ @DisplayName("랭킹 1위를 생성할 수 있다")
+ @Test
+ void createsFirstRank() {
+ // arrange
+ Integer firstRank = 1;
+
+ // act
+ ProductRank firstPlaceRank = new ProductRank(
+ ProductRank.PeriodType.WEEKLY,
+ LocalDate.of(2024, 12, 9),
+ 1L,
+ firstRank,
+ 1000L,
+ 5000L,
+ 10000L
+ );
+
+ // assert
+ assertThat(firstPlaceRank.getRank()).isEqualTo(firstRank);
+ assertThat(firstPlaceRank.getRank()).isGreaterThanOrEqualTo(1);
+ }
+}
+
From f2b01ae1e2ae785bb8caa1ff8e8d7d2be7fb002f Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:09:14 +0900
Subject: [PATCH 07/14] =?UTF-8?q?feat:=20Product=20Rank=20=EB=8F=84?=
=?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/loopers/domain/rank/ProductRank.java | 166 ++++++++++++++++++
1 file changed, 166 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
new file mode 100644
index 000000000..576eb158d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
@@ -0,0 +1,166 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 상품 랭킹 Materialized View 엔티티.
+ *
+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다.
+ *
+ *
+ * Materialized View 설계:
+ *
+ * - 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
+ * - 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
+ * - TOP 100만 저장하여 조회 성능 최적화
+ *
+ *
+ *
+ * 인덱스 전략:
+ *
+ * - 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
+ * - 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(
+ name = "mv_product_rank",
+ indexes = {
+ @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
+ @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
+ }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductRank {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ /**
+ * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간)
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "period_type", nullable = false, length = 20)
+ private PeriodType periodType;
+
+ /**
+ * 기간 시작일
+ *
+ * - 주간: 해당 주의 월요일 (ISO 8601 기준)
+ * - 월간: 해당 월의 1일
+ *
+ */
+ @Column(name = "period_start_date", nullable = false)
+ private LocalDate periodStartDate;
+
+ /**
+ * 상품 ID
+ */
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ /**
+ * 랭킹 (1-100)
+ */
+ @Column(name = "rank", nullable = false)
+ private Integer rank;
+
+ /**
+ * 좋아요 수
+ */
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ /**
+ * 판매량
+ */
+ @Column(name = "sales_count", nullable = false)
+ private Long salesCount;
+
+ /**
+ * 조회 수
+ */
+ @Column(name = "view_count", nullable = false)
+ private Long viewCount;
+
+ /**
+ * 생성 시각
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 시각
+ */
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ /**
+ * ProductRank 인스턴스를 생성합니다.
+ *
+ * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY)
+ * @param periodStartDate 기간 시작일
+ * @param productId 상품 ID
+ * @param rank 랭킹 (1-100)
+ * @param likeCount 좋아요 수
+ * @param salesCount 판매량
+ * @param viewCount 조회 수
+ */
+ public ProductRank(
+ PeriodType periodType,
+ LocalDate periodStartDate,
+ Long productId,
+ Integer rank,
+ Long likeCount,
+ Long salesCount,
+ Long viewCount
+ ) {
+ this.periodType = periodType;
+ this.periodStartDate = periodStartDate;
+ this.productId = productId;
+ this.rank = rank;
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ this.createdAt = LocalDateTime.now();
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 랭킹 정보를 업데이트합니다.
+ *
+ * @param rank 새로운 랭킹
+ * @param likeCount 좋아요 수
+ * @param salesCount 판매량
+ * @param viewCount 조회 수
+ */
+ public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) {
+ this.rank = rank;
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 기간 타입 열거형.
+ */
+ public enum PeriodType {
+ WEEKLY, // 주간
+ MONTHLY // 월간
+ }
+}
+
From 6e0110b78c4515226117f0952e7aab62b522640b Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:09:38 +0900
Subject: [PATCH 08/14] =?UTF-8?q?feat:=20Product=20Rank=20Repository=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
---
.../domain/rank/ProductRankRepository.java | 59 ++++++++++++
.../rank/ProductRankRepositoryImpl.java | 95 +++++++++++++++++++
2 files changed, 154 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
new file mode 100644
index 000000000..f30679126
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
@@ -0,0 +1,59 @@
+package com.loopers.domain.rank;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRank 도메인 Repository 인터페이스.
+ *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다.
+ *
+ */
+public interface ProductRankRepository {
+
+ /**
+ * 특정 기간의 랭킹 데이터를 저장합니다.
+ *
+ * 기존 데이터가 있으면 삭제 후 새로 저장합니다 (UPSERT 방식).
+ *
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ * @param ranks 저장할 랭킹 리스트 (TOP 100)
+ */
+ void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks);
+
+ /**
+ * 특정 기간의 랭킹 데이터를 조회합니다.
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ * @param limit 조회할 랭킹 수 (기본: 100)
+ * @return 랭킹 리스트 (rank 오름차순)
+ */
+ List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit);
+
+ /**
+ * 특정 기간의 특정 상품 랭킹을 조회합니다.
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ * @param productId 상품 ID
+ * @return 랭킹 정보 (없으면 Optional.empty())
+ */
+ Optional findByPeriodAndProductId(
+ ProductRank.PeriodType periodType,
+ LocalDate periodStartDate,
+ Long productId
+ );
+
+ /**
+ * 특정 기간의 기존 랭킹 데이터를 삭제합니다.
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ */
+ void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate);
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
new file mode 100644
index 000000000..d50aa8991
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
@@ -0,0 +1,95 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankRepository;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.Query;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRank Repository 구현체.
+ *
+ * Materialized View에 저장된 상품 랭킹 데이터를 관리합니다.
+ *
+ */
+@Slf4j
+@Repository
+public class ProductRankRepositoryImpl implements ProductRankRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Override
+ @Transactional
+ public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks) {
+ // 기존 데이터 삭제
+ deleteByPeriod(periodType, periodStartDate);
+
+ // 새 데이터 저장
+ for (ProductRank rank : ranks) {
+ entityManager.persist(rank);
+ }
+
+ log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}",
+ periodType, periodStartDate, ranks.size());
+ }
+
+ @Override
+ public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) {
+ String jpql = "SELECT pr FROM ProductRank pr " +
+ "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " +
+ "ORDER BY pr.rank ASC";
+
+ return entityManager.createQuery(jpql, ProductRank.class)
+ .setParameter("periodType", periodType)
+ .setParameter("periodStartDate", periodStartDate)
+ .setMaxResults(limit)
+ .getResultList();
+ }
+
+ @Override
+ public Optional findByPeriodAndProductId(
+ ProductRank.PeriodType periodType,
+ LocalDate periodStartDate,
+ Long productId
+ ) {
+ String jpql = "SELECT pr FROM ProductRank pr " +
+ "WHERE pr.periodType = :periodType " +
+ "AND pr.periodStartDate = :periodStartDate " +
+ "AND pr.productId = :productId";
+
+ try {
+ ProductRank rank = entityManager.createQuery(jpql, ProductRank.class)
+ .setParameter("periodType", periodType)
+ .setParameter("periodStartDate", periodStartDate)
+ .setParameter("productId", productId)
+ .getSingleResult();
+ return Optional.of(rank);
+ } catch (jakarta.persistence.NoResultException e) {
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ @Transactional
+ public void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate) {
+ String jpql = "DELETE FROM ProductRank pr " +
+ "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate";
+
+ int deletedCount = entityManager.createQuery(jpql)
+ .setParameter("periodType", periodType)
+ .setParameter("periodStartDate", periodStartDate)
+ .executeUpdate();
+
+ log.debug("ProductRank 삭제 완료: periodType={}, periodStartDate={}, deletedCount={}",
+ periodType, periodStartDate, deletedCount);
+ }
+}
+
From db8f80cec56349731c483852eb8ab2b557365319 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:14:45 +0900
Subject: [PATCH 09/14] =?UTF-8?q?test:=20Product=20Rank=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ProductRankAggregationProcessorTest.java | 121 +++++++++
.../ProductRankAggregationReaderTest.java | 152 +++++++++++
.../ProductRankAggregationWriterTest.java | 255 ++++++++++++++++++
3 files changed, 528 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java
new file mode 100644
index 000000000..a87ec4585
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java
@@ -0,0 +1,121 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ProductRankAggregationProcessor 테스트.
+ */
+class ProductRankAggregationProcessorTest {
+
+ private final ProductRankAggregationProcessor processor = new ProductRankAggregationProcessor();
+
+ @DisplayName("주간 기간 정보를 설정할 수 있다")
+ @Test
+ void setsWeeklyPeriod() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+
+ // act
+ processor.setPeriod(periodType, targetDate);
+
+ // assert
+ assertThat(processor.getPeriodType()).isEqualTo(periodType);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일
+ }
+
+ @DisplayName("월간 기간 정보를 설정할 수 있다")
+ @Test
+ void setsMonthlyPeriod() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15);
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY;
+
+ // act
+ processor.setPeriod(periodType, targetDate);
+
+ // assert
+ assertThat(processor.getPeriodType()).isEqualTo(periodType);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 월의 1일
+ }
+
+ @DisplayName("주간 기간 설정 시 해당 주의 월요일을 시작일로 계산한다")
+ @Test
+ void calculatesWeekStartAsMonday_whenSettingWeeklyPeriod() {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+
+ // 월요일
+ LocalDate monday = LocalDate.of(2024, 12, 9);
+ // 수요일
+ LocalDate wednesday = LocalDate.of(2024, 12, 11);
+ // 일요일
+ LocalDate sunday = LocalDate.of(2024, 12, 15);
+
+ // act & assert
+ processor.setPeriod(periodType, monday);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(monday);
+
+ processor.setPeriod(periodType, wednesday);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(monday);
+
+ processor.setPeriod(periodType, sunday);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(monday);
+ }
+
+ @DisplayName("월간 기간 설정 시 해당 월의 1일을 시작일로 계산한다")
+ @Test
+ void calculatesMonthStartAsFirstDay_whenSettingMonthlyPeriod() {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY;
+ LocalDate expectedStart = LocalDate.of(2024, 12, 1);
+
+ // 1일
+ LocalDate firstDay = LocalDate.of(2024, 12, 1);
+ // 15일
+ LocalDate midDay = LocalDate.of(2024, 12, 15);
+ // 마지막 일
+ LocalDate lastDay = LocalDate.of(2024, 12, 31);
+
+ // act & assert
+ processor.setPeriod(periodType, firstDay);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart);
+
+ processor.setPeriod(periodType, midDay);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart);
+
+ processor.setPeriod(periodType, lastDay);
+ assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart);
+ }
+
+ @DisplayName("기간 정보를 여러 번 설정할 수 있다")
+ @Test
+ void canSetPeriodMultipleTimes() {
+ // arrange
+ LocalDate firstDate = LocalDate.of(2024, 12, 15);
+ LocalDate secondDate = LocalDate.of(2024, 11, 20);
+
+ // act
+ processor.setPeriod(ProductRank.PeriodType.WEEKLY, firstDate);
+ ProductRank.PeriodType firstType = processor.getPeriodType();
+ LocalDate firstStart = processor.getPeriodStartDate();
+
+ processor.setPeriod(ProductRank.PeriodType.MONTHLY, secondDate);
+ ProductRank.PeriodType secondType = processor.getPeriodType();
+ LocalDate secondStart = processor.getPeriodStartDate();
+
+ // assert
+ assertThat(firstType).isEqualTo(ProductRank.PeriodType.WEEKLY);
+ assertThat(firstStart).isEqualTo(LocalDate.of(2024, 12, 9));
+
+ assertThat(secondType).isEqualTo(ProductRank.PeriodType.MONTHLY);
+ assertThat(secondStart).isEqualTo(LocalDate.of(2024, 11, 1));
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
new file mode 100644
index 000000000..50e225b7e
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
@@ -0,0 +1,152 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.batch.item.data.RepositoryItemReader;
+import org.springframework.data.repository.PagingAndSortingRepository;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * ProductRankAggregationReader 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductRankAggregationReaderTest {
+
+ @Mock
+ private ProductMetricsRepository productMetricsRepository;
+
+ @Mock
+ private PagingAndSortingRepository jpaRepository;
+
+ @DisplayName("주간 Reader를 생성할 수 있다")
+ @Test
+ void createsWeeklyReader() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ assertThat(itemReader.getName()).isEqualTo("weeklyReader");
+ }
+
+ @DisplayName("주간 Reader는 해당 주의 월요일부터 다음 주 월요일까지의 데이터를 조회한다")
+ @Test
+ void weeklyReaderQueriesFromMondayToNextMonday() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ // 주간 시작일은 해당 주의 월요일이어야 함
+ // 2024-12-15(일) -> 2024-12-09(월)이 시작일
+ }
+
+ @DisplayName("월간 Reader를 생성할 수 있다")
+ @Test
+ void createsMonthlyReader() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15);
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ assertThat(itemReader.getName()).isEqualTo("monthlyReader");
+ }
+
+ @DisplayName("월간 Reader는 해당 월의 1일부터 다음 달 1일까지의 데이터를 조회한다")
+ @Test
+ void monthlyReaderQueriesFromFirstDayToNextMonth() {
+ // arrange
+ LocalDate targetDate = LocalDate.of(2024, 12, 15);
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate);
+
+ // assert
+ assertThat(itemReader).isNotNull();
+ // 월간 시작일은 해당 월의 1일이어야 함
+ // 2024-12-15 -> 2024-12-01이 시작일
+ }
+
+ @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다")
+ @Test
+ void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() {
+ // arrange
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // 월요일
+ LocalDate monday = LocalDate.of(2024, 12, 9);
+ // 수요일
+ LocalDate wednesday = LocalDate.of(2024, 12, 11);
+ // 일요일
+ LocalDate sunday = LocalDate.of(2024, 12, 15);
+
+ // act
+ RepositoryItemReader mondayReader = reader.createWeeklyReader(monday);
+ RepositoryItemReader wednesdayReader = reader.createWeeklyReader(wednesday);
+ RepositoryItemReader sundayReader = reader.createWeeklyReader(sunday);
+
+ // assert
+ assertThat(mondayReader).isNotNull();
+ assertThat(wednesdayReader).isNotNull();
+ assertThat(sundayReader).isNotNull();
+ // 모두 같은 주의 월요일부터 시작해야 함
+ }
+
+ @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다")
+ @Test
+ void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() {
+ // arrange
+ when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository);
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // 1일
+ LocalDate firstDay = LocalDate.of(2024, 12, 1);
+ // 15일
+ LocalDate midDay = LocalDate.of(2024, 12, 15);
+ // 마지막 일
+ LocalDate lastDay = LocalDate.of(2024, 12, 31);
+
+ // act
+ RepositoryItemReader firstDayReader = reader.createMonthlyReader(firstDay);
+ RepositoryItemReader midDayReader = reader.createMonthlyReader(midDay);
+ RepositoryItemReader lastDayReader = reader.createMonthlyReader(lastDay);
+
+ // assert
+ assertThat(firstDayReader).isNotNull();
+ assertThat(midDayReader).isNotNull();
+ assertThat(lastDayReader).isNotNull();
+ // 모두 같은 월의 1일부터 시작해야 함
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
new file mode 100644
index 000000000..3f2e2bcd3
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
@@ -0,0 +1,255 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.batch.item.Chunk;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * ProductRankAggregationWriter 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductRankAggregationWriterTest {
+
+ @Mock
+ private ProductRankRepository productRankRepository;
+
+ @Mock
+ private ProductRankAggregationProcessor productRankAggregationProcessor;
+
+ @InjectMocks
+ private ProductRankAggregationWriter writer;
+
+ @DisplayName("Chunk를 정상적으로 처리할 수 있다")
+ @Test
+ void writesChunk_successfully() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ List items = createProductMetricsList(3);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
+
+ List savedRanks = ranksCaptor.getValue();
+ assertThat(savedRanks).isNotEmpty();
+ }
+
+ @DisplayName("빈 Chunk는 처리하지 않는다")
+ @Test
+ void skipsEmptyChunk() throws Exception {
+ // arrange
+ Chunk chunk = new Chunk<>(new ArrayList<>());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ verify(productRankRepository, never()).saveRanks(any(), any(), any());
+ }
+
+ @DisplayName("기간 정보가 설정되지 않으면 처리하지 않는다")
+ @Test
+ void skipsProcessing_whenPeriodNotSet() throws Exception {
+ // arrange
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(null);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null);
+
+ List items = createProductMetricsList(3);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ verify(productRankRepository, never()).saveRanks(any(), any(), any());
+ }
+
+ @DisplayName("같은 product_id를 가진 메트릭을 합산한다")
+ @Test
+ void aggregatesMetricsByProductId() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ // 같은 productId를 가진 여러 메트릭
+ List items = new ArrayList<>();
+ ProductMetrics metrics1 = new ProductMetrics(1L);
+ metrics1.incrementLikeCount();
+ metrics1.incrementSalesCount(10);
+ items.add(metrics1);
+
+ ProductMetrics metrics2 = new ProductMetrics(1L); // 같은 productId
+ metrics2.incrementLikeCount();
+ metrics2.incrementSalesCount(20);
+ items.add(metrics2);
+
+ ProductMetrics metrics3 = new ProductMetrics(2L); // 다른 productId
+ metrics3.incrementLikeCount();
+ items.add(metrics3);
+
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
+
+ List savedRanks = ranksCaptor.getValue();
+ assertThat(savedRanks).hasSize(2); // productId 1과 2
+
+ // productId 1의 메트릭이 합산되었는지 확인
+ ProductRank rank1 = savedRanks.stream()
+ .filter(r -> r.getProductId().equals(1L))
+ .findFirst()
+ .orElseThrow();
+ assertThat(rank1.getLikeCount()).isEqualTo(2L); // 1 + 1
+ assertThat(rank1.getSalesCount()).isEqualTo(30L); // 10 + 20
+ }
+
+ @DisplayName("종합 점수 기준으로 TOP 100을 선정한다")
+ @Test
+ void selectsTop100ByScore() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ // 150개의 메트릭 생성 (TOP 100만 선택되어야 함)
+ List items = createProductMetricsList(150);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
+
+ List savedRanks = ranksCaptor.getValue();
+ assertThat(savedRanks).hasSizeLessThanOrEqualTo(100);
+ }
+
+ @DisplayName("랭킹을 1부터 시작하여 부여한다")
+ @Test
+ void assignsRanksStartingFromOne() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ List items = createProductMetricsList(5);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
+
+ List savedRanks = ranksCaptor.getValue();
+ assertThat(savedRanks).extracting(ProductRank::getRank)
+ .containsExactly(1, 2, 3, 4, 5);
+ }
+
+ @DisplayName("주간 랭킹을 저장한다")
+ @Test
+ void savesWeeklyRanks() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ List items = createProductMetricsList(3);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(ProductRank.PeriodType.WEEKLY), eq(periodStartDate), any());
+ }
+
+ @DisplayName("월간 랭킹을 저장한다")
+ @Test
+ void savesMonthlyRanks() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 1);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ List items = createProductMetricsList(3);
+ Chunk chunk = new Chunk<>(items);
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ verify(productRankRepository, times(1))
+ .saveRanks(eq(ProductRank.PeriodType.MONTHLY), eq(periodStartDate), any());
+ }
+
+ /**
+ * 테스트용 ProductMetrics 리스트를 생성합니다.
+ *
+ * @param count 생성할 항목 수
+ * @return ProductMetrics 리스트
+ */
+ private List createProductMetricsList(int count) {
+ List items = new ArrayList<>();
+ for (long i = 1; i <= count; i++) {
+ ProductMetrics metrics = new ProductMetrics(i);
+ // 점수가 높은 순서로 생성 (i가 클수록 점수가 높음)
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount((int) (i * 10));
+ metrics.incrementViewCount();
+ items.add(metrics);
+ }
+ return items;
+ }
+}
+
From 43dca99395add511b818e10b2dc1dce4384d7b9f Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:15:03 +0900
Subject: [PATCH 10/14] =?UTF-8?q?feat:=20Product=20Rank=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../rank/ProductRankAggregationProcessor.java | 74 +++++++
.../rank/ProductRankAggregationReader.java | 123 +++++++++++
.../rank/ProductRankAggregationWriter.java | 203 ++++++++++++++++++
.../batch/rank/ProductRankJobConfig.java | 189 ++++++++++++++++
4 files changed, 589 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java
new file mode 100644
index 000000000..2cf591cef
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java
@@ -0,0 +1,74 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRank;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.temporal.TemporalAdjusters;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/**
+ * ProductRank 집계를 위한 Processor.
+ *
+ * 기간 정보를 관리하고 Writer에서 사용할 수 있도록 제공합니다.
+ * 실제 집계는 Writer에서 Chunk 단위로 수행됩니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+public class ProductRankAggregationProcessor {
+
+ private ProductRank.PeriodType periodType;
+ private LocalDate periodStartDate;
+
+ /**
+ * 기간 정보를 설정합니다.
+ *
+ * Job 파라미터에서 주입받아 설정합니다.
+ *
+ *
+ * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY)
+ * @param targetDate 기준 날짜
+ */
+ public void setPeriod(ProductRank.PeriodType periodType, LocalDate targetDate) {
+ this.periodType = periodType;
+
+ if (periodType == ProductRank.PeriodType.WEEKLY) {
+ // 주간 시작일: 해당 주의 월요일
+ this.periodStartDate = targetDate.with(java.time.DayOfWeek.MONDAY);
+ } else if (periodType == ProductRank.PeriodType.MONTHLY) {
+ // 월간 시작일: 해당 월의 1일
+ this.periodStartDate = targetDate.with(TemporalAdjusters.firstDayOfMonth());
+ }
+ }
+
+ /**
+ * 기간 타입을 반환합니다.
+ *
+ * @return 기간 타입
+ */
+ public ProductRank.PeriodType getPeriodType() {
+ return periodType;
+ }
+
+ /**
+ * 기간 시작일을 반환합니다.
+ *
+ * @return 기간 시작일
+ */
+ public LocalDate getPeriodStartDate() {
+ return periodStartDate;
+ }
+
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java
new file mode 100644
index 000000000..449cb18d2
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java
@@ -0,0 +1,123 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.data.RepositoryItemReader;
+import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.repository.PagingAndSortingRepository;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.temporal.TemporalAdjusters;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * ProductRank 집계를 위한 Spring Batch ItemReader Factory.
+ *
+ * 주간/월간 집계를 위해 특정 기간의 모든 ProductMetrics를 읽습니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터 조회
+ * - 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터 조회
+ * - 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankAggregationReader {
+
+ private final ProductMetricsRepository productMetricsRepository;
+
+ /**
+ * 주간 집계를 위한 Reader를 생성합니다.
+ *
+ * 해당 주의 월요일부터 일요일까지의 ProductMetrics를 조회합니다.
+ *
+ *
+ * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능)
+ * @return RepositoryItemReader 인스턴스
+ */
+ public RepositoryItemReader createWeeklyReader(LocalDate targetDate) {
+ // 주간 시작일 계산 (월요일)
+ LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY);
+ LocalDateTime startDateTime = weekStart.atStartOfDay();
+
+ // 주간 종료일 계산 (다음 주 월요일 00:00:00)
+ LocalDate weekEnd = weekStart.plusWeeks(1);
+ LocalDateTime endDateTime = weekEnd.atStartOfDay();
+
+ log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}",
+ targetDate, weekStart, weekEnd);
+
+ return createReader(startDateTime, endDateTime, "weeklyReader");
+ }
+
+ /**
+ * 월간 집계를 위한 Reader를 생성합니다.
+ *
+ * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다.
+ *
+ *
+ * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능)
+ * @return RepositoryItemReader 인스턴스
+ */
+ public RepositoryItemReader createMonthlyReader(LocalDate targetDate) {
+ // 월간 시작일 계산 (1일)
+ LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth());
+ LocalDateTime startDateTime = monthStart.atStartOfDay();
+
+ // 월간 종료일 계산 (다음 달 1일 00:00:00)
+ LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth());
+ LocalDateTime endDateTime = monthEnd.atStartOfDay();
+
+ log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}",
+ targetDate, monthStart, monthEnd);
+
+ return createReader(startDateTime, endDateTime, "monthlyReader");
+ }
+
+ /**
+ * ProductMetrics를 읽는 ItemReader를 생성합니다.
+ *
+ * @param startDateTime 조회 시작 시각
+ * @param endDateTime 조회 종료 시각
+ * @param readerName Reader 이름
+ * @return RepositoryItemReader 인스턴스
+ */
+ private RepositoryItemReader createReader(
+ LocalDateTime startDateTime,
+ LocalDateTime endDateTime,
+ String readerName
+ ) {
+ // 정렬 기준 설정 (product_id 기준 오름차순)
+ Map sorts = new HashMap<>();
+ sorts.put("productId", Sort.Direction.ASC);
+
+ // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로
+ // 기술적 제약으로 인해 getJpaRepository()를 통해 접근
+ PagingAndSortingRepository jpaRepository =
+ productMetricsRepository.getJpaRepository();
+
+ return new RepositoryItemReaderBuilder()
+ .name(readerName)
+ .repository(jpaRepository)
+ .methodName("findByUpdatedAtBetween")
+ .arguments(startDateTime, endDateTime)
+ .pageSize(100) // Chunk 크기와 동일하게 설정
+ .sorts(sorts)
+ .build();
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
new file mode 100644
index 000000000..60cabe9ed
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
@@ -0,0 +1,203 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.time.temporal.TemporalAdjusters;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+/**
+ * ProductRank를 Materialized View에 저장하는 Writer.
+ *
+ * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Chunk 단위로 받은 ProductMetrics를 집계하여 TOP 100 랭킹 생성
+ * - 기존 데이터 삭제 후 새 데이터 저장 (UPSERT 방식)
+ * - 주간/월간 랭킹을 별도로 관리
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankAggregationWriter implements ItemWriter {
+
+ private final ProductRankRepository productRankRepository;
+ private final ProductRankAggregationProcessor productRankAggregationProcessor;
+
+ /**
+ * ProductMetrics Chunk를 집계하여 Materialized View에 저장합니다.
+ *
+ * Chunk 단위로 받은 ProductMetrics를 집계하여 TOP 100 랭킹을 생성하고 저장합니다.
+ *
+ *
+ * @param chunk 처리할 ProductMetrics Chunk
+ * @throws Exception 처리 중 오류 발생 시
+ */
+ @Override
+ public void write(Chunk extends ProductMetrics> chunk) throws Exception {
+ List extends ProductMetrics> items = chunk.getItems();
+
+ if (items.isEmpty()) {
+ log.warn("ProductMetrics Chunk가 비어있습니다.");
+ return;
+ }
+
+ log.info("ProductRank Chunk 처리 시작: itemCount={}", items.size());
+
+ // Processor에서 기간 정보 가져오기
+ ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType();
+ LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate();
+
+ if (periodType == null || periodStartDate == null) {
+ log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다.");
+ return;
+ }
+
+ // 같은 product_id를 가진 메트릭을 합산
+ Map aggregatedMap = items.stream()
+ .collect(Collectors.groupingBy(
+ ProductMetrics::getProductId,
+ Collectors.reducing(
+ new AggregatedMetrics(0L, 0L, 0L),
+ metrics -> new AggregatedMetrics(
+ metrics.getLikeCount(),
+ metrics.getSalesCount(),
+ metrics.getViewCount()
+ ),
+ (a, b) -> new AggregatedMetrics(
+ a.getLikeCount() + b.getLikeCount(),
+ a.getSalesCount() + b.getSalesCount(),
+ a.getViewCount() + b.getViewCount()
+ )
+ )
+ ));
+
+ // 종합 점수 계산 및 정렬하여 TOP 100 선정
+ List ranks = aggregatedMap.entrySet().stream()
+ .map(entry -> {
+ Long productId = entry.getKey();
+ AggregatedMetrics aggregated = entry.getValue();
+ double score = calculateScore(aggregated);
+ return new RankedMetrics(productId, aggregated, score);
+ })
+ .sorted(Comparator.comparing(RankedMetrics::getScore).reversed())
+ .limit(100) // TOP 100
+ .collect(Collectors.collectingAndThen(
+ Collectors.toList(),
+ rankedList -> IntStream.range(0, rankedList.size())
+ .mapToObj(i -> {
+ RankedMetrics ranked = rankedList.get(i);
+ AggregatedMetrics aggregated = ranked.getAggregated();
+ return new ProductRank(
+ periodType,
+ periodStartDate,
+ ranked.getProductId(),
+ i + 1, // 랭킹 (1부터 시작)
+ aggregated.getLikeCount(),
+ aggregated.getSalesCount(),
+ aggregated.getViewCount()
+ );
+ })
+ .collect(Collectors.toList())
+ ));
+
+ // Materialized View에 저장
+ productRankRepository.saveRanks(periodType, periodStartDate, ranks);
+
+ log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, rankCount={}",
+ periodType, periodStartDate, ranks.size());
+ }
+
+ /**
+ * 종합 점수를 계산합니다.
+ *
+ * 가중치:
+ *
+ * - 좋아요: 0.3
+ * - 판매량: 0.5
+ * - 조회수: 0.2
+ *
+ *
+ *
+ * @param aggregated AggregatedMetrics
+ * @return 종합 점수
+ */
+ private double calculateScore(AggregatedMetrics aggregated) {
+ return aggregated.getLikeCount() * 0.3
+ + aggregated.getSalesCount() * 0.5
+ + aggregated.getViewCount() * 0.2;
+ }
+
+ /**
+ * 랭킹이 부여된 메트릭을 담는 내부 클래스.
+ */
+ private static class RankedMetrics {
+ private final Long productId;
+ private final AggregatedMetrics aggregated;
+ private final double score;
+
+ public RankedMetrics(Long productId, AggregatedMetrics aggregated, double score) {
+ this.productId = productId;
+ this.aggregated = aggregated;
+ this.score = score;
+ }
+
+ public Long getProductId() {
+ return productId;
+ }
+
+ public AggregatedMetrics getAggregated() {
+ return aggregated;
+ }
+
+ public double getScore() {
+ return score;
+ }
+ }
+
+ /**
+ * 집계된 메트릭을 담는 내부 클래스.
+ */
+ private static class AggregatedMetrics {
+ private final Long likeCount;
+ private final Long salesCount;
+ private final Long viewCount;
+
+ public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ }
+
+ public Long getLikeCount() {
+ return likeCount;
+ }
+
+ public Long getSalesCount() {
+ return salesCount;
+ }
+
+ public Long getViewCount() {
+ return viewCount;
+ }
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
new file mode 100644
index 000000000..0e36ff849
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
@@ -0,0 +1,189 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRank;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.batch.item.ItemReader;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * ProductRank 집계를 위한 Spring Batch Job Configuration.
+ *
+ * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터를 집계
+ * - 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터를 집계
+ * - Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
+ * - Materialized View 저장: 조회 성능 최적화를 위한 TOP 100 랭킹 저장
+ *
+ *
+ *
+ * Job 파라미터:
+ *
+ * - periodType: 기간 타입 (WEEKLY 또는 MONTHLY)
+ * - targetDate: 기준 날짜 (yyyyMMdd 형식, 예: "20241215")
+ *
+ *
+ *
+ * 실행 예시:
+ *
+ * // 주간 집계
+ * java -jar commerce-batch.jar \
+ * --spring.batch.job.names=productRankAggregationJob \
+ * periodType=WEEKLY targetDate=20241215
+ *
+ * // 월간 집계
+ * java -jar commerce-batch.jar \
+ * --spring.batch.job.names=productRankAggregationJob \
+ * periodType=MONTHLY targetDate=20241215
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Configuration
+@RequiredArgsConstructor
+public class ProductRankJobConfig {
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final ProductRankAggregationReader productRankAggregationReader;
+ private final ProductRankAggregationProcessor productRankAggregationProcessor;
+ private final ProductRankAggregationWriter productRankAggregationWriter;
+
+ /**
+ * ProductRank 집계 Job을 생성합니다.
+ *
+ * @return ProductRank 집계 Job
+ */
+ @Bean
+ public Job productRankAggregationJob(Step productRankAggregationStep) {
+ return new JobBuilder("productRankAggregationJob", jobRepository)
+ .start(productRankAggregationStep)
+ .build();
+ }
+
+ /**
+ * ProductRank 집계 Step을 생성합니다.
+ *
+ * Chunk-Oriented Processing을 사용하여:
+ *
+ * - Reader: 특정 기간의 product_metrics를 페이징하여 읽기
+ * - Processor: 메트릭을 합산하여 TOP 100 랭킹 생성
+ * - Writer: Materialized View에 저장
+ *
+ *
+ *
+ * @param productRankReader ProductRank Reader (StepScope Bean)
+ * @param productRankProcessor ProductRank Processor
+ * @param productRankWriter ProductRank Writer
+ * @return ProductRank 집계 Step
+ */
+ @Bean
+ public Step productRankAggregationStep(
+ ItemReader productRankReader,
+ ItemProcessor productRankProcessor,
+ ItemWriter productRankWriter
+ ) {
+ return new StepBuilder("productRankAggregationStep", jobRepository)
+ .chunk(100, transactionManager) // Chunk 크기: 100
+ .reader(productRankReader)
+ .processor(productRankProcessor)
+ .writer(productRankWriter)
+ .build();
+ }
+
+ /**
+ * ProductRank Reader를 생성합니다.
+ *
+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다.
+ *
+ *
+ * @param periodType 기간 타입 (Job 파라미터에서 주입)
+ * @param targetDate 기준 날짜 (Job 파라미터에서 주입)
+ * @return ProductRank Reader (StepScope로 선언되어 Step 실행 시 생성)
+ */
+ @Bean
+ @StepScope
+ public ItemReader productRankReader(
+ @Value("#{jobParameters['periodType']}") String periodType,
+ @Value("#{jobParameters['targetDate']}") String targetDate
+ ) {
+ LocalDate date = parseDate(targetDate);
+ ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase());
+
+ // Processor에 기간 정보 설정
+ productRankAggregationProcessor.setPeriod(period, date);
+
+ if (period == ProductRank.PeriodType.WEEKLY) {
+ return productRankAggregationReader.createWeeklyReader(date);
+ } else {
+ return productRankAggregationReader.createMonthlyReader(date);
+ }
+ }
+
+ /**
+ * ProductRank Processor를 주입받습니다.
+ *
+ * 현재는 pass-through이지만, 향후 필터링 로직 추가 가능.
+ *
+ *
+ * @return ProductRank Processor (pass-through)
+ */
+ @Bean
+ public ItemProcessor productRankProcessor() {
+ return item -> item; // pass-through
+ }
+
+ /**
+ * ProductRank Writer를 주입받습니다.
+ *
+ * @return ProductRank Writer
+ */
+ @Bean
+ public ItemWriter productRankWriter() {
+ return productRankAggregationWriter;
+ }
+
+ /**
+ * 날짜 문자열을 LocalDate로 파싱합니다.
+ *
+ * @param dateStr 날짜 문자열 (yyyyMMdd 형식)
+ * @return 파싱된 날짜
+ */
+ private LocalDate parseDate(String dateStr) {
+ if (dateStr == null || dateStr.isEmpty()) {
+ log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다.");
+ return LocalDate.now();
+ }
+
+ try {
+ return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd"));
+ } catch (Exception e) {
+ log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e);
+ return LocalDate.now();
+ }
+ }
+}
+
From e70dadf5f96844f5b80c18ffb17fd6727d1c50ad Mon Sep 17 00:00:00 2001
From: minor7295
Date: Tue, 30 Dec 2025 02:21:40 +0900
Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EC=9D=BC=EA=B0=84,=20=EC=A3=BC?=
=?UTF-8?q?=EA=B0=84,=20=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=EC=9D=84?=
=?UTF-8?q?=20=EC=A0=9C=EA=B3=B5=ED=95=98=EB=8A=94=20api=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../application/ranking/RankingService.java | 182 ++++++++++++++++++
.../com/loopers/domain/rank/ProductRank.java | 119 ++++++++++++
.../domain/rank/ProductRankRepository.java | 39 ++++
.../rank/ProductRankRepositoryImpl.java | 63 ++++++
.../api/ranking/RankingV1Controller.java | 39 +++-
5 files changed, 440 insertions(+), 2 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
index df6305b83..d4b0d38d2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -46,10 +46,49 @@ public class RankingService {
private final ProductService productService;
private final BrandService brandService;
private final RankingSnapshotService rankingSnapshotService;
+ private final com.loopers.domain.rank.ProductRankRepository productRankRepository;
/**
* 랭킹을 조회합니다 (페이징).
*
+ * 기간별(일간/주간/월간) 랭킹을 조회합니다.
+ *
+ *
+ * 기간별 조회 방식:
+ *
+ * - DAILY: Redis ZSET에서 조회 (기존 방식)
+ * - WEEKLY: Materialized View에서 조회
+ * - MONTHLY: Materialized View에서 조회
+ *
+ *
+ *
+ * Graceful Degradation (DAILY만 적용):
+ *
+ * - Redis 장애 시 스냅샷으로 Fallback
+ * - 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
+ *
+ *
+ *
+ * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate)
+ * @param periodType 기간 타입 (DAILY, WEEKLY, MONTHLY)
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ @Transactional(readOnly = true)
+ public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) {
+ if (periodType == PeriodType.DAILY) {
+ // 일간 랭킹: 기존 Redis 방식
+ return getRankings(date, page, size);
+ } else {
+ // 주간/월간 랭킹: Materialized View에서 조회
+ return getRankingsFromMaterializedView(date, periodType, page, size);
+ }
+ }
+
+ /**
+ * 랭킹을 조회합니다 (페이징) - 일간 랭킹 전용.
+ *
* ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다.
*
*
@@ -304,6 +343,149 @@ private Long getProductRankFromRedis(Long productId, LocalDate date) {
return rank + 1;
}
+ /**
+ * Materialized View에서 주간/월간 랭킹을 조회합니다.
+ *
+ * Materialized View에 저장된 TOP 100 랭킹을 조회하고, 상품 정보를 Aggregation하여 반환합니다.
+ *
+ *
+ * @param date 기준 날짜
+ * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY)
+ * @param page 페이지 번호 (0부터 시작)
+ * @param size 페이지당 항목 수
+ * @return 랭킹 조회 결과
+ */
+ private RankingsResponse getRankingsFromMaterializedView(
+ LocalDate date,
+ PeriodType periodType,
+ int page,
+ int size
+ ) {
+ // 기간 시작일 계산
+ LocalDate periodStartDate;
+ if (periodType == PeriodType.WEEKLY) {
+ // 주간: 해당 주의 월요일
+ periodStartDate = date.with(java.time.DayOfWeek.MONDAY);
+ } else {
+ // 월간: 해당 월의 1일
+ periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth());
+ }
+
+ // Materialized View에서 랭킹 조회
+ com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType =
+ periodType == PeriodType.WEEKLY
+ ? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY
+ : com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY;
+
+ List ranks = productRankRepository.findByPeriod(
+ rankPeriodType, periodStartDate, 100
+ );
+
+ if (ranks.isEmpty()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ // 페이징 처리
+ long start = (long) page * size;
+ long end = Math.min(start + size, ranks.size());
+
+ if (start >= ranks.size()) {
+ return RankingsResponse.empty(page, size);
+ }
+
+ List pagedRanks = ranks.subList((int) start, (int) end);
+
+ // 상품 ID 추출
+ List productIds = pagedRanks.stream()
+ .map(com.loopers.domain.rank.ProductRank::getProductId)
+ .toList();
+
+ // 상품 정보 배치 조회
+ List products = productService.getProducts(productIds);
+
+ // 상품 ID → Product Map 생성
+ Map productMap = products.stream()
+ .collect(Collectors.toMap(Product::getId, product -> product));
+
+ // 브랜드 ID 수집
+ List brandIds = products.stream()
+ .map(Product::getBrandId)
+ .distinct()
+ .toList();
+
+ // 브랜드 배치 조회
+ Map brandMap = brandService.getBrands(brandIds).stream()
+ .collect(Collectors.toMap(Brand::getId, brand -> brand));
+
+ // 랭킹 항목 생성
+ List rankingItems = new ArrayList<>();
+ for (com.loopers.domain.rank.ProductRank rank : pagedRanks) {
+ Long productId = rank.getProductId();
+ Product product = productMap.get(productId);
+
+ if (product == null) {
+ log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId);
+ continue;
+ }
+
+ Brand brand = brandMap.get(product.getBrandId());
+ if (brand == null) {
+ log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}",
+ productId, product.getBrandId());
+ continue;
+ }
+
+ ProductDetail productDetail = ProductDetail.from(
+ product,
+ brand.getName(),
+ rank.getLikeCount()
+ );
+
+ // 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산)
+ double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount());
+
+ rankingItems.add(new RankingItem(
+ rank.getRank().longValue(),
+ score,
+ productDetail
+ ));
+ }
+
+ boolean hasNext = end < ranks.size();
+ return new RankingsResponse(rankingItems, page, size, hasNext);
+ }
+
+ /**
+ * 종합 점수를 계산합니다.
+ *
+ * 가중치:
+ *
+ * - 좋아요: 0.3
+ * - 판매량: 0.5
+ * - 조회수: 0.2
+ *
+ *
+ *
+ * @param likeCount 좋아요 수
+ * @param salesCount 판매량
+ * @param viewCount 조회 수
+ * @return 종합 점수
+ */
+ private double calculateScore(Long likeCount, Long salesCount, Long viewCount) {
+ return (likeCount != null ? likeCount : 0L) * 0.3
+ + (salesCount != null ? salesCount : 0L) * 0.5
+ + (viewCount != null ? viewCount : 0L) * 0.2;
+ }
+
+ /**
+ * 기간 타입 열거형.
+ */
+ public enum PeriodType {
+ DAILY, // 일간
+ WEEKLY, // 주간
+ MONTHLY // 월간
+ }
+
/**
* 랭킹 조회 결과.
*
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
new file mode 100644
index 000000000..30abae5d3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
@@ -0,0 +1,119 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 상품 랭킹 Materialized View 엔티티.
+ *
+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다.
+ *
+ *
+ * Materialized View 설계:
+ *
+ * - 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
+ * - 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
+ * - TOP 100만 저장하여 조회 성능 최적화
+ *
+ *
+ *
+ * 인덱스 전략:
+ *
+ * - 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
+ * - 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(
+ name = "mv_product_rank",
+ indexes = {
+ @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"),
+ @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id")
+ }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductRank {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ /**
+ * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간)
+ */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "period_type", nullable = false, length = 20)
+ private PeriodType periodType;
+
+ /**
+ * 기간 시작일
+ *
+ * - 주간: 해당 주의 월요일 (ISO 8601 기준)
+ * - 월간: 해당 월의 1일
+ *
+ */
+ @Column(name = "period_start_date", nullable = false)
+ private LocalDate periodStartDate;
+
+ /**
+ * 상품 ID
+ */
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ /**
+ * 랭킹 (1-100)
+ */
+ @Column(name = "rank", nullable = false)
+ private Integer rank;
+
+ /**
+ * 좋아요 수
+ */
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ /**
+ * 판매량
+ */
+ @Column(name = "sales_count", nullable = false)
+ private Long salesCount;
+
+ /**
+ * 조회 수
+ */
+ @Column(name = "view_count", nullable = false)
+ private Long viewCount;
+
+ /**
+ * 생성 시각
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 시각
+ */
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ /**
+ * 기간 타입 열거형.
+ */
+ public enum PeriodType {
+ WEEKLY, // 주간
+ MONTHLY // 월간
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
new file mode 100644
index 000000000..92b1529e7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java
@@ -0,0 +1,39 @@
+package com.loopers.domain.rank;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRank 도메인 Repository 인터페이스.
+ *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다.
+ *
+ */
+public interface ProductRankRepository {
+
+ /**
+ * 특정 기간의 랭킹 데이터를 조회합니다.
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ * @param limit 조회할 랭킹 수 (기본: 100)
+ * @return 랭킹 리스트 (rank 오름차순)
+ */
+ List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit);
+
+ /**
+ * 특정 기간의 특정 상품 랭킹을 조회합니다.
+ *
+ * @param periodType 기간 타입
+ * @param periodStartDate 기간 시작일
+ * @param productId 상품 ID
+ * @return 랭킹 정보 (없으면 Optional.empty())
+ */
+ Optional findByPeriodAndProductId(
+ ProductRank.PeriodType periodType,
+ LocalDate periodStartDate,
+ Long productId
+ );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
new file mode 100644
index 000000000..046c6a035
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
@@ -0,0 +1,63 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankRepository;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRank Repository 구현체.
+ *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다.
+ *
+ */
+@Slf4j
+@Repository
+public class ProductRankRepositoryImpl implements ProductRankRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Override
+ public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) {
+ String jpql = "SELECT pr FROM ProductRank pr " +
+ "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " +
+ "ORDER BY pr.rank ASC";
+
+ return entityManager.createQuery(jpql, ProductRank.class)
+ .setParameter("periodType", periodType)
+ .setParameter("periodStartDate", periodStartDate)
+ .setMaxResults(limit)
+ .getResultList();
+ }
+
+ @Override
+ public Optional findByPeriodAndProductId(
+ ProductRank.PeriodType periodType,
+ LocalDate periodStartDate,
+ Long productId
+ ) {
+ String jpql = "SELECT pr FROM ProductRank pr " +
+ "WHERE pr.periodType = :periodType " +
+ "AND pr.periodStartDate = :periodStartDate " +
+ "AND pr.productId = :productId";
+
+ try {
+ ProductRank rank = entityManager.createQuery(jpql, ProductRank.class)
+ .setParameter("periodType", periodType)
+ .setParameter("periodStartDate", periodStartDate)
+ .setParameter("productId", productId)
+ .getSingleResult();
+ return Optional.of(rank);
+ } catch (jakarta.persistence.NoResultException e) {
+ return Optional.empty();
+ }
+ }
+}
+
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 ecbae6157..2a34d7f21 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
@@ -33,10 +33,19 @@ public class RankingV1Controller {
/**
* 랭킹을 조회합니다.
*
- * 날짜별 랭킹을 페이징하여 조회합니다.
+ * 기간별(일간/주간/월간) 랭킹을 페이징하여 조회합니다.
+ *
+ *
+ * 기간 타입:
+ *
+ * - DAILY: 일간 랭킹 (Redis ZSET에서 조회)
+ * - WEEKLY: 주간 랭킹 (Materialized View에서 조회)
+ * - MONTHLY: 월간 랭킹 (Materialized View에서 조회)
+ *
*
*
* @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜)
+ * @param period 기간 타입 (DAILY, WEEKLY, MONTHLY, 기본값: DAILY)
* @param page 페이지 번호 (기본값: 0)
* @param size 페이지당 항목 수 (기본값: 20)
* @return 랭킹 목록을 담은 API 응답
@@ -44,12 +53,16 @@ public class RankingV1Controller {
@GetMapping
public ApiResponse getRankings(
@RequestParam(required = false) String date,
+ @RequestParam(required = false, defaultValue = "DAILY") String period,
@RequestParam(required = false, defaultValue = "0") int page,
@RequestParam(required = false, defaultValue = "20") int size
) {
// 날짜 파라미터 검증 및 기본값 처리
LocalDate targetDate = parseDate(date);
+ // 기간 타입 파싱 및 검증
+ RankingService.PeriodType periodType = parsePeriodType(period);
+
// 페이징 검증
if (page < 0) {
page = 0;
@@ -61,7 +74,7 @@ public ApiResponse getRankings(
size = 100; // 최대 100개로 제한
}
- RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size);
+ RankingService.RankingsResponse result = rankingService.getRankings(targetDate, periodType, page, size);
return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result));
}
@@ -86,4 +99,26 @@ private LocalDate parseDate(String dateStr) {
return LocalDate.now(ZoneId.of("UTC"));
}
}
+
+ /**
+ * 기간 타입 문자열을 PeriodType으로 파싱합니다.
+ *
+ * 파싱 실패 시 DAILY를 반환합니다.
+ *
+ *
+ * @param periodStr 기간 타입 문자열 (DAILY, WEEKLY, MONTHLY)
+ * @return 파싱된 기간 타입 (실패 시 DAILY)
+ */
+ private RankingService.PeriodType parsePeriodType(String periodStr) {
+ if (periodStr == null || periodStr.isBlank()) {
+ return RankingService.PeriodType.DAILY;
+ }
+
+ try {
+ return RankingService.PeriodType.valueOf(periodStr.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ // 파싱 실패 시 DAILY 반환
+ return RankingService.PeriodType.DAILY;
+ }
+ }
}
From 7fd8a80f6dee385c310f1039c99476c06e999876 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 2 Jan 2026 02:40:19 +0900
Subject: [PATCH 12/14] =?UTF-8?q?refractor:=20=EB=9E=AD=ED=82=B9=20?=
=?UTF-8?q?=EC=A7=91=EA=B3=84=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=97=AC?=
=?UTF-8?q?=EB=9F=AC=20step=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
=?UTF-8?q?=ED=95=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../loopers/domain/rank/ProductRankScore.java | 141 ++++++++++
.../rank/ProductRankScoreRepository.java | 68 +++++
.../rank/ProductRankAggregationWriter.java | 203 --------------
.../rank/ProductRankCalculationProcessor.java | 87 ++++++
.../rank/ProductRankCalculationReader.java | 72 +++++
.../rank/ProductRankCalculationWriter.java | 82 ++++++
.../batch/rank/ProductRankJobConfig.java | 124 +++++++--
.../ProductRankScoreAggregationWriter.java | 170 ++++++++++++
.../rank/ProductRankScoreRepositoryImpl.java | 100 +++++++
.../ProductRankAggregationWriterTest.java | 255 ------------------
10 files changed, 816 insertions(+), 486 deletions(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
delete mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
new file mode 100644
index 000000000..97653efd6
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java
@@ -0,0 +1,141 @@
+package com.loopers.domain.rank;
+
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 상품 랭킹 점수 집계 임시 엔티티.
+ *
+ * Step 1 (집계 로직 계산)에서 사용하는 임시 테이블입니다.
+ * product_id별로 점수를 집계하여 저장하며, 랭킹 번호는 저장하지 않습니다.
+ *
+ *
+ * 사용 목적:
+ *
+ * - Step 1에서 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
+ * - Step 2에서 전체 데이터를 읽어서 TOP 100 선정 및 랭킹 번호 부여
+ *
+ *
+ *
+ * 인덱스 전략:
+ *
+ * - product_id에 유니크 인덱스: 같은 product_id는 하나의 레코드만 존재 (UPSERT 방식)
+ * - score에 인덱스: Step 2에서 정렬 시 성능 최적화
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(
+ name = "tmp_product_rank_score",
+ indexes = {
+ @Index(name = "idx_product_id", columnList = "product_id", unique = true),
+ @Index(name = "idx_score", columnList = "score")
+ }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductRankScore {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Long id;
+
+ /**
+ * 상품 ID
+ */
+ @Column(name = "product_id", nullable = false, unique = true)
+ private Long productId;
+
+ /**
+ * 좋아요 수 (집계된 값)
+ */
+ @Column(name = "like_count", nullable = false)
+ private Long likeCount;
+
+ /**
+ * 판매량 (집계된 값)
+ */
+ @Column(name = "sales_count", nullable = false)
+ private Long salesCount;
+
+ /**
+ * 조회 수 (집계된 값)
+ */
+ @Column(name = "view_count", nullable = false)
+ private Long viewCount;
+
+ /**
+ * 종합 점수
+ *
+ * 가중치:
+ *
+ * - 좋아요: 0.3
+ * - 판매량: 0.5
+ * - 조회수: 0.2
+ *
+ *
+ */
+ @Column(name = "score", nullable = false)
+ private Double score;
+
+ /**
+ * 메트릭 값을 설정합니다.
+ *
+ * Repository에서만 사용하는 내부 메서드입니다.
+ *
+ */
+ public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) {
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ this.score = score;
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 생성 시각
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 시각
+ */
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ /**
+ * ProductRankScore 인스턴스를 생성합니다.
+ *
+ * @param productId 상품 ID
+ * @param likeCount 좋아요 수
+ * @param salesCount 판매량
+ * @param viewCount 조회 수
+ * @param score 종합 점수
+ */
+ public ProductRankScore(
+ Long productId,
+ Long likeCount,
+ Long salesCount,
+ Long viewCount,
+ Double score
+ ) {
+ this.productId = productId;
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ this.score = score;
+ this.createdAt = LocalDateTime.now();
+ this.updatedAt = LocalDateTime.now();
+ }
+
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
new file mode 100644
index 000000000..149357a81
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
@@ -0,0 +1,68 @@
+package com.loopers.domain.rank;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRankScore 도메인 Repository 인터페이스.
+ *
+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다.
+ *
+ */
+public interface ProductRankScoreRepository {
+
+ /**
+ * ProductRankScore를 저장합니다.
+ *
+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식).
+ *
+ *
+ * @param score 저장할 ProductRankScore
+ */
+ void save(ProductRankScore score);
+
+ /**
+ * 여러 ProductRankScore를 저장합니다.
+ *
+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식).
+ *
+ *
+ * @param scores 저장할 ProductRankScore 리스트
+ */
+ void saveAll(List scores);
+
+ /**
+ * product_id로 ProductRankScore를 조회합니다.
+ *
+ * @param productId 상품 ID
+ * @return ProductRankScore (없으면 Optional.empty())
+ */
+ Optional findByProductId(Long productId);
+
+ /**
+ * 모든 ProductRankScore를 점수 내림차순으로 조회합니다.
+ *
+ * Step 2에서 TOP 100 선정을 위해 사용합니다.
+ *
+ *
+ * @param limit 조회할 최대 개수 (기본: 전체)
+ * @return ProductRankScore 리스트 (점수 내림차순)
+ */
+ List findAllOrderByScoreDesc(int limit);
+
+ /**
+ * 모든 ProductRankScore를 조회합니다.
+ *
+ * @return ProductRankScore 리스트
+ */
+ List findAll();
+
+ /**
+ * 모든 ProductRankScore를 삭제합니다.
+ *
+ * Step 2 완료 후 임시 테이블을 정리하기 위해 사용합니다.
+ *
+ */
+ void deleteAll();
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
deleted file mode 100644
index 60cabe9ed..000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriter.java
+++ /dev/null
@@ -1,203 +0,0 @@
-package com.loopers.infrastructure.batch.rank;
-
-import com.loopers.domain.metrics.ProductMetrics;
-import com.loopers.domain.rank.ProductRank;
-import com.loopers.domain.rank.ProductRankRepository;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.batch.item.Chunk;
-import org.springframework.batch.item.ItemWriter;
-import org.springframework.stereotype.Component;
-
-import java.time.LocalDate;
-import java.time.temporal.TemporalAdjusters;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-
-/**
- * ProductRank를 Materialized View에 저장하는 Writer.
- *
- * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다.
- *
- *
- * 구현 의도:
- *
- * - Chunk 단위로 받은 ProductMetrics를 집계하여 TOP 100 랭킹 생성
- * - 기존 데이터 삭제 후 새 데이터 저장 (UPSERT 방식)
- * - 주간/월간 랭킹을 별도로 관리
- *
- *
- *
- * @author Loopers
- * @version 1.0
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class ProductRankAggregationWriter implements ItemWriter {
-
- private final ProductRankRepository productRankRepository;
- private final ProductRankAggregationProcessor productRankAggregationProcessor;
-
- /**
- * ProductMetrics Chunk를 집계하여 Materialized View에 저장합니다.
- *
- * Chunk 단위로 받은 ProductMetrics를 집계하여 TOP 100 랭킹을 생성하고 저장합니다.
- *
- *
- * @param chunk 처리할 ProductMetrics Chunk
- * @throws Exception 처리 중 오류 발생 시
- */
- @Override
- public void write(Chunk extends ProductMetrics> chunk) throws Exception {
- List extends ProductMetrics> items = chunk.getItems();
-
- if (items.isEmpty()) {
- log.warn("ProductMetrics Chunk가 비어있습니다.");
- return;
- }
-
- log.info("ProductRank Chunk 처리 시작: itemCount={}", items.size());
-
- // Processor에서 기간 정보 가져오기
- ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType();
- LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate();
-
- if (periodType == null || periodStartDate == null) {
- log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다.");
- return;
- }
-
- // 같은 product_id를 가진 메트릭을 합산
- Map aggregatedMap = items.stream()
- .collect(Collectors.groupingBy(
- ProductMetrics::getProductId,
- Collectors.reducing(
- new AggregatedMetrics(0L, 0L, 0L),
- metrics -> new AggregatedMetrics(
- metrics.getLikeCount(),
- metrics.getSalesCount(),
- metrics.getViewCount()
- ),
- (a, b) -> new AggregatedMetrics(
- a.getLikeCount() + b.getLikeCount(),
- a.getSalesCount() + b.getSalesCount(),
- a.getViewCount() + b.getViewCount()
- )
- )
- ));
-
- // 종합 점수 계산 및 정렬하여 TOP 100 선정
- List ranks = aggregatedMap.entrySet().stream()
- .map(entry -> {
- Long productId = entry.getKey();
- AggregatedMetrics aggregated = entry.getValue();
- double score = calculateScore(aggregated);
- return new RankedMetrics(productId, aggregated, score);
- })
- .sorted(Comparator.comparing(RankedMetrics::getScore).reversed())
- .limit(100) // TOP 100
- .collect(Collectors.collectingAndThen(
- Collectors.toList(),
- rankedList -> IntStream.range(0, rankedList.size())
- .mapToObj(i -> {
- RankedMetrics ranked = rankedList.get(i);
- AggregatedMetrics aggregated = ranked.getAggregated();
- return new ProductRank(
- periodType,
- periodStartDate,
- ranked.getProductId(),
- i + 1, // 랭킹 (1부터 시작)
- aggregated.getLikeCount(),
- aggregated.getSalesCount(),
- aggregated.getViewCount()
- );
- })
- .collect(Collectors.toList())
- ));
-
- // Materialized View에 저장
- productRankRepository.saveRanks(periodType, periodStartDate, ranks);
-
- log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, rankCount={}",
- periodType, periodStartDate, ranks.size());
- }
-
- /**
- * 종합 점수를 계산합니다.
- *
- * 가중치:
- *
- * - 좋아요: 0.3
- * - 판매량: 0.5
- * - 조회수: 0.2
- *
- *
- *
- * @param aggregated AggregatedMetrics
- * @return 종합 점수
- */
- private double calculateScore(AggregatedMetrics aggregated) {
- return aggregated.getLikeCount() * 0.3
- + aggregated.getSalesCount() * 0.5
- + aggregated.getViewCount() * 0.2;
- }
-
- /**
- * 랭킹이 부여된 메트릭을 담는 내부 클래스.
- */
- private static class RankedMetrics {
- private final Long productId;
- private final AggregatedMetrics aggregated;
- private final double score;
-
- public RankedMetrics(Long productId, AggregatedMetrics aggregated, double score) {
- this.productId = productId;
- this.aggregated = aggregated;
- this.score = score;
- }
-
- public Long getProductId() {
- return productId;
- }
-
- public AggregatedMetrics getAggregated() {
- return aggregated;
- }
-
- public double getScore() {
- return score;
- }
- }
-
- /**
- * 집계된 메트릭을 담는 내부 클래스.
- */
- private static class AggregatedMetrics {
- private final Long likeCount;
- private final Long salesCount;
- private final Long viewCount;
-
- public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {
- this.likeCount = likeCount;
- this.salesCount = salesCount;
- this.viewCount = viewCount;
- }
-
- public Long getLikeCount() {
- return likeCount;
- }
-
- public Long getSalesCount() {
- return salesCount;
- }
-
- public Long getViewCount() {
- return viewCount;
- }
- }
-}
-
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
new file mode 100644
index 000000000..cafcbc4cc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
@@ -0,0 +1,87 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankScore;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+
+/**
+ * ProductRankScore를 ProductRank로 변환하는 Processor.
+ *
+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다.
+ * ProductRankScore를 읽어서 랭킹 번호를 부여하고 ProductRank로 변환합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - ProductRankScore에 랭킹 번호 부여 (1부터 시작)
+ * - TOP 100만 선정 (나머지는 null 반환하여 필터링)
+ * - ProductRank로 변환
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankCalculationProcessor implements ItemProcessor {
+
+ private final ProductRankAggregationProcessor productRankAggregationProcessor;
+ private final ThreadLocal currentRank = ThreadLocal.withInitial(() -> 0);
+ private static final int TOP_RANK_LIMIT = 100;
+
+ /**
+ * ProductRankScore를 ProductRank로 변환합니다.
+ *
+ * 랭킹 번호를 부여하고, TOP 100에 포함되는 경우에만 ProductRank를 반환합니다.
+ *
+ *
+ * @param score ProductRankScore
+ * @return ProductRank (TOP 100에 포함되는 경우), null (그 외)
+ * @throws Exception 처리 중 오류 발생 시
+ */
+ @Override
+ public ProductRank process(ProductRankScore score) throws Exception {
+ int rank = currentRank.get() + 1;
+ currentRank.set(rank);
+
+ // TOP 100에 포함되지 않으면 null 반환 (필터링)
+ if (rank > TOP_RANK_LIMIT) {
+ return null;
+ }
+
+ // 기간 정보 가져오기
+ ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType();
+ LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate();
+
+ if (periodType == null || periodStartDate == null) {
+ log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다.");
+ return null;
+ }
+
+ // ProductRank 생성 (랭킹 번호 부여)
+ ProductRank productRank = new ProductRank(
+ periodType,
+ periodStartDate,
+ score.getProductId(),
+ rank, // 랭킹 번호 (1부터 시작)
+ score.getLikeCount(),
+ score.getSalesCount(),
+ score.getViewCount()
+ );
+
+ // Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시)
+ if (rank == TOP_RANK_LIMIT) {
+ currentRank.remove();
+ }
+
+ return productRank;
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java
new file mode 100644
index 000000000..4b997f66c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java
@@ -0,0 +1,72 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.rank.ProductRankScore;
+import com.loopers.domain.rank.ProductRankScoreRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.ItemReader;
+import org.springframework.batch.item.NonTransientResourceException;
+import org.springframework.batch.item.ParseException;
+import org.springframework.batch.item.UnexpectedInputException;
+import org.springframework.stereotype.Component;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * ProductRankScore를 읽는 Reader.
+ *
+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다.
+ * ProductRankScore 테이블에서 점수 내림차순으로 모든 데이터를 읽습니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Step 1에서 집계된 모든 ProductRankScore를 읽기
+ * - 점수 내림차순으로 정렬된 데이터를 제공
+ * - TOP 100 선정을 위해 전체 데이터를 읽어야 함
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankCalculationReader implements ItemReader {
+
+ private final ProductRankScoreRepository productRankScoreRepository;
+ private Iterator scoreIterator;
+ private boolean initialized = false;
+
+ /**
+ * ProductRankScore를 읽습니다.
+ *
+ * 첫 호출 시 모든 데이터를 조회하고, 이후 Iterator를 통해 하나씩 반환합니다.
+ *
+ *
+ * @return ProductRankScore (더 이상 없으면 null)
+ * @throws UnexpectedInputException 예상치 못한 입력 오류
+ * @throws ParseException 파싱 오류
+ * @throws NonTransientResourceException 일시적이지 않은 리소스 오류
+ */
+ @Override
+ public ProductRankScore read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException {
+ if (!initialized) {
+ // 첫 호출 시 모든 데이터를 점수 내림차순으로 조회
+ List scores = productRankScoreRepository.findAllOrderByScoreDesc(0);
+ this.scoreIterator = scores.iterator();
+ this.initialized = true;
+
+ log.info("ProductRankScore 조회 완료: totalCount={}", scores.size());
+ }
+
+ if (scoreIterator.hasNext()) {
+ return scoreIterator.next();
+ }
+
+ return null; // 더 이상 읽을 데이터가 없음
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java
new file mode 100644
index 000000000..71fd8ea5c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java
@@ -0,0 +1,82 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * ProductRank를 Materialized View에 저장하는 Writer.
+ *
+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다.
+ * 랭킹 번호가 부여된 ProductRank를 Materialized View에 저장합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Chunk 단위로 받은 ProductRank를 수집하고 저장
+ * - 각 Chunk마다 전체 ProductRank를 저장 (saveRanks가 delete + insert를 수행)
+ * - 기존 데이터 삭제 후 새 데이터 저장 (delete + insert 방식)
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankCalculationWriter implements ItemWriter {
+
+ private final ProductRankRepository productRankRepository;
+ private final ProductRankAggregationProcessor productRankAggregationProcessor;
+ private final List allRanks = new java.util.ArrayList<>();
+
+ /**
+ * ProductRank Chunk를 수집하고 저장합니다.
+ *
+ * 모든 Chunk를 메모리에 모아두고, 각 Chunk마다 전체를 저장합니다.
+ * saveRanks가 delete + insert를 수행하므로, 각 Chunk마다 전체를 저장해도 문제없습니다.
+ *
+ *
+ * @param chunk 처리할 ProductRank Chunk
+ * @throws Exception 처리 중 오류 발생 시
+ */
+ @Override
+ public void write(Chunk extends ProductRank> chunk) throws Exception {
+ List extends ProductRank> items = chunk.getItems()
+ .stream()
+ .filter(item -> item != null) // null 필터링 (TOP 100에 포함되지 않은 항목)
+ .collect(Collectors.toList());
+
+ if (items.isEmpty()) {
+ return;
+ }
+
+ // 기간 정보 가져오기
+ ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType();
+ LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate();
+
+ if (periodType == null || periodStartDate == null) {
+ log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다.");
+ return;
+ }
+
+ // 모든 Chunk를 수집
+ allRanks.addAll(items);
+ log.debug("ProductRank Chunk 수집: count={}, total={}", items.size(), allRanks.size());
+
+ // 각 Chunk마다 전체를 저장 (saveRanks가 delete + insert를 수행하므로 문제없음)
+ log.info("ProductRank 저장: periodType={}, periodStartDate={}, total={}",
+ periodType, periodStartDate, allRanks.size());
+ productRankRepository.saveRanks(periodType, periodStartDate, allRanks);
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
index 0e36ff849..a8c06a0e5 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
@@ -2,6 +2,7 @@
import com.loopers.domain.metrics.ProductMetrics;
import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankScore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
@@ -30,8 +31,8 @@
*
* 구현 의도:
*
- * - 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터를 집계
- * - 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터를 집계
+ * - Step 1 (집계 로직 계산): 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
+ * - Step 2 (랭킹 로직 실행): 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여
* - Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
* - Materialized View 저장: 조회 성능 최적화를 위한 TOP 100 랭킹 저장
*
@@ -70,47 +71,97 @@ public class ProductRankJobConfig {
private final PlatformTransactionManager transactionManager;
private final ProductRankAggregationReader productRankAggregationReader;
private final ProductRankAggregationProcessor productRankAggregationProcessor;
- private final ProductRankAggregationWriter productRankAggregationWriter;
+ private final ProductRankScoreAggregationWriter productRankScoreAggregationWriter;
+ private final ProductRankCalculationReader productRankCalculationReader;
+ private final ProductRankCalculationProcessor productRankCalculationProcessor;
+ private final ProductRankCalculationWriter productRankCalculationWriter;
/**
* ProductRank 집계 Job을 생성합니다.
+ *
+ * 2-Step 구조:
+ *
+ * - Step 1: 집계 로직 계산 (점수 집계)
+ * - Step 2: 랭킹 로직 실행 (TOP 100 선정 및 랭킹 번호 부여)
+ *
+ *
*
+ * @param scoreAggregationStep Step 1: 집계 로직 계산 Step
+ * @param rankingCalculationStep Step 2: 랭킹 로직 실행 Step
* @return ProductRank 집계 Job
*/
@Bean
- public Job productRankAggregationJob(Step productRankAggregationStep) {
+ public Job productRankAggregationJob(
+ Step scoreAggregationStep,
+ Step rankingCalculationStep
+ ) {
return new JobBuilder("productRankAggregationJob", jobRepository)
- .start(productRankAggregationStep)
+ .start(scoreAggregationStep) // Step 1 먼저 실행
+ .next(rankingCalculationStep) // Step 1 완료 후 Step 2 실행
.build();
}
/**
- * ProductRank 집계 Step을 생성합니다.
+ * Step 1: 집계 로직 계산 Step을 생성합니다.
+ *
+ * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다.
+ *
*
* Chunk-Oriented Processing을 사용하여:
*
* - Reader: 특정 기간의 product_metrics를 페이징하여 읽기
- * - Processor: 메트릭을 합산하여 TOP 100 랭킹 생성
- * - Writer: Materialized View에 저장
+ * - Processor: Pass-through (필터링 필요 시 추가 가능)
+ * - Writer: product_id별로 점수 집계하여 ProductRankScore 테이블에 저장
*
*
*
* @param productRankReader ProductRank Reader (StepScope Bean)
- * @param productRankProcessor ProductRank Processor
- * @param productRankWriter ProductRank Writer
- * @return ProductRank 집계 Step
+ * @param productRankScoreWriter ProductRankScore Writer
+ * @return 집계 로직 계산 Step
*/
@Bean
- public Step productRankAggregationStep(
+ public Step scoreAggregationStep(
ItemReader productRankReader,
- ItemProcessor productRankProcessor,
- ItemWriter productRankWriter
+ ItemWriter productRankScoreWriter
) {
- return new StepBuilder("productRankAggregationStep", jobRepository)
+ return new StepBuilder("scoreAggregationStep", jobRepository)
.chunk(100, transactionManager) // Chunk 크기: 100
.reader(productRankReader)
- .processor(productRankProcessor)
- .writer(productRankWriter)
+ .processor(item -> item) // Pass-through
+ .writer(productRankScoreWriter)
+ .build();
+ }
+
+ /**
+ * Step 2: 랭킹 로직 실행 Step을 생성합니다.
+ *
+ * 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여하여 Materialized View에 저장합니다.
+ *
+ *
+ * Chunk-Oriented Processing을 사용하여:
+ *
+ * - Reader: ProductRankScore 테이블에서 모든 데이터를 점수 내림차순으로 읽기
+ * - Processor: TOP 100 선정 및 랭킹 번호 부여
+ * - Writer: ProductRank를 수집하고 저장
+ *
+ *
+ *
+ * @param productRankScoreReader ProductRankScore Reader
+ * @param productRankCalculationProcessor ProductRank 계산 Processor
+ * @param productRankCalculationWriter ProductRank 계산 Writer
+ * @return 랭킹 로직 실행 Step
+ */
+ @Bean
+ public Step rankingCalculationStep(
+ ItemReader productRankScoreReader,
+ ItemProcessor productRankCalculationProcessor,
+ ItemWriter productRankCalculationWriter
+ ) {
+ return new StepBuilder("rankingCalculationStep", jobRepository)
+ .chunk(100, transactionManager) // Chunk 크기: 100
+ .reader(productRankScoreReader)
+ .processor(productRankCalculationProcessor)
+ .writer(productRankCalculationWriter)
.build();
}
@@ -144,26 +195,43 @@ public ItemReader productRankReader(
}
/**
- * ProductRank Processor를 주입받습니다.
- *
- * 현재는 pass-through이지만, 향후 필터링 로직 추가 가능.
- *
+ * Step 1용 ProductRankScore Writer를 주입받습니다.
+ *
+ * @return ProductRankScore Writer
+ */
+ @Bean
+ public ItemWriter productRankScoreWriter() {
+ return productRankScoreAggregationWriter;
+ }
+
+ /**
+ * Step 2용 ProductRankScore Reader를 주입받습니다.
+ *
+ * @return ProductRankScore Reader
+ */
+ @Bean
+ public ItemReader productRankScoreReader() {
+ return productRankCalculationReader;
+ }
+
+ /**
+ * Step 2용 ProductRank 계산 Processor를 주입받습니다.
*
- * @return ProductRank Processor (pass-through)
+ * @return ProductRank 계산 Processor
*/
@Bean
- public ItemProcessor productRankProcessor() {
- return item -> item; // pass-through
+ public ItemProcessor productRankCalculationProcessor() {
+ return productRankCalculationProcessor;
}
/**
- * ProductRank Writer를 주입받습니다.
+ * Step 2용 ProductRank 계산 Writer를 주입받습니다.
*
- * @return ProductRank Writer
+ * @return ProductRank 계산 Writer
*/
@Bean
- public ItemWriter productRankWriter() {
- return productRankAggregationWriter;
+ public ItemWriter productRankCalculationWriter() {
+ return productRankCalculationWriter;
}
/**
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
new file mode 100644
index 000000000..f1e3d6404
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
@@ -0,0 +1,170 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRankScore;
+import com.loopers.domain.rank.ProductRankScoreRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * ProductRankScore 집계를 위한 Writer.
+ *
+ * Step 1 (집계 로직 계산 Step)에서 사용합니다.
+ * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고,
+ * ProductRankScore 임시 테이블에 저장합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Chunk 단위로 받은 ProductMetrics를 product_id별로 집계
+ * - 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
+ * - ProductRankScore 테이블에 저장 (랭킹 번호 없이)
+ * - 같은 product_id가 여러 Chunk에 걸쳐 있을 경우 UPSERT 방식으로 누적
+ *
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ProductRankScoreAggregationWriter implements ItemWriter {
+
+ private final ProductRankScoreRepository productRankScoreRepository;
+
+ /**
+ * ProductMetrics Chunk를 집계하여 ProductRankScore 테이블에 저장합니다.
+ *
+ * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고 저장합니다.
+ * 같은 product_id가 여러 Chunk에 걸쳐 있을 경우, 기존 데이터를 조회하여 누적한 후 저장합니다.
+ *
+ *
+ * @param chunk 처리할 ProductMetrics Chunk
+ * @throws Exception 처리 중 오류 발생 시
+ */
+ @Override
+ public void write(Chunk extends ProductMetrics> chunk) throws Exception {
+ List extends ProductMetrics> items = chunk.getItems();
+
+ if (items.isEmpty()) {
+ log.warn("ProductMetrics Chunk가 비어있습니다.");
+ return;
+ }
+
+ log.debug("ProductRankScore Chunk 처리 시작: itemCount={}", items.size());
+
+ // 같은 product_id를 가진 메트릭을 합산 (Chunk 내에서)
+ Map chunkAggregatedMap = items.stream()
+ .collect(Collectors.groupingBy(
+ ProductMetrics::getProductId,
+ Collectors.reducing(
+ new AggregatedMetrics(0L, 0L, 0L),
+ metrics -> new AggregatedMetrics(
+ metrics.getLikeCount(),
+ metrics.getSalesCount(),
+ metrics.getViewCount()
+ ),
+ (a, b) -> new AggregatedMetrics(
+ a.getLikeCount() + b.getLikeCount(),
+ a.getSalesCount() + b.getSalesCount(),
+ a.getViewCount() + b.getViewCount()
+ )
+ )
+ ));
+
+ // 기존 데이터와 누적하여 ProductRankScore 생성
+ List scores = chunkAggregatedMap.entrySet().stream()
+ .map(entry -> {
+ Long productId = entry.getKey();
+ AggregatedMetrics chunkAggregated = entry.getValue();
+
+ // 기존 데이터 조회
+ java.util.Optional existing = productRankScoreRepository.findByProductId(productId);
+
+ // 기존 데이터와 누적
+ Long totalLikeCount = chunkAggregated.getLikeCount();
+ Long totalSalesCount = chunkAggregated.getSalesCount();
+ Long totalViewCount = chunkAggregated.getViewCount();
+
+ if (existing.isPresent()) {
+ ProductRankScore existingScore = existing.get();
+ totalLikeCount += existingScore.getLikeCount();
+ totalSalesCount += existingScore.getSalesCount();
+ totalViewCount += existingScore.getViewCount();
+ }
+
+ // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
+ double score = calculateScore(totalLikeCount, totalSalesCount, totalViewCount);
+
+ return new ProductRankScore(
+ productId,
+ totalLikeCount,
+ totalSalesCount,
+ totalViewCount,
+ score
+ );
+ })
+ .collect(Collectors.toList());
+
+ // 저장 (기존 데이터가 있으면 덮어쓰기)
+ productRankScoreRepository.saveAll(scores);
+
+ log.debug("ProductRankScore 저장 완료: count={}", scores.size());
+ }
+
+ /**
+ * 종합 점수를 계산합니다.
+ *
+ * 가중치:
+ *
+ * - 좋아요: 0.3
+ * - 판매량: 0.5
+ * - 조회수: 0.2
+ *
+ *
+ *
+ * @param likeCount 좋아요 수
+ * @param salesCount 판매량
+ * @param viewCount 조회 수
+ * @return 종합 점수
+ */
+ private double calculateScore(Long likeCount, Long salesCount, Long viewCount) {
+ return likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2;
+ }
+
+ /**
+ * 집계된 메트릭을 담는 내부 클래스.
+ */
+ private static class AggregatedMetrics {
+ private final Long likeCount;
+ private final Long salesCount;
+ private final Long viewCount;
+
+ public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) {
+ this.likeCount = likeCount;
+ this.salesCount = salesCount;
+ this.viewCount = viewCount;
+ }
+
+ public Long getLikeCount() {
+ return likeCount;
+ }
+
+ public Long getSalesCount() {
+ return salesCount;
+ }
+
+ public Long getViewCount() {
+ return viewCount;
+ }
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
new file mode 100644
index 000000000..b210d9ce2
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
@@ -0,0 +1,100 @@
+package com.loopers.infrastructure.rank;
+
+import com.loopers.domain.rank.ProductRankScore;
+import com.loopers.domain.rank.ProductRankScoreRepository;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRankScore Repository 구현체.
+ *
+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다.
+ *
+ */
+@Slf4j
+@Repository
+public class ProductRankScoreRepositoryImpl implements ProductRankScoreRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Override
+ @Transactional
+ public void save(ProductRankScore score) {
+ Optional existing = findByProductId(score.getProductId());
+
+ if (existing.isPresent()) {
+ // 기존 레코드가 있으면 덮어쓰기 (Writer에서 이미 누적된 값을 전달받음)
+ ProductRankScore existingScore = existing.get();
+ existingScore.setMetrics(
+ score.getLikeCount(),
+ score.getSalesCount(),
+ score.getViewCount(),
+ score.getScore()
+ );
+ entityManager.merge(existingScore);
+ log.debug("ProductRankScore 업데이트: productId={}", score.getProductId());
+ } else {
+ // 없으면 새로 생성
+ entityManager.persist(score);
+ log.debug("ProductRankScore 생성: productId={}", score.getProductId());
+ }
+ }
+
+ @Override
+ @Transactional
+ public void saveAll(List scores) {
+ for (ProductRankScore score : scores) {
+ save(score);
+ }
+ log.info("ProductRankScore 일괄 저장 완료: count={}", scores.size());
+ }
+
+ @Override
+ public Optional findByProductId(Long productId) {
+ String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId = :productId";
+
+ try {
+ ProductRankScore score = entityManager.createQuery(jpql, ProductRankScore.class)
+ .setParameter("productId", productId)
+ .getSingleResult();
+ return Optional.of(score);
+ } catch (jakarta.persistence.NoResultException e) {
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public List findAllOrderByScoreDesc(int limit) {
+ String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC";
+
+ jakarta.persistence.TypedQuery query =
+ entityManager.createQuery(jpql, ProductRankScore.class);
+ if (limit > 0) {
+ query.setMaxResults(limit);
+ }
+
+ return query.getResultList();
+ }
+
+ @Override
+ public List findAll() {
+ String jpql = "SELECT prs FROM ProductRankScore prs";
+ return entityManager.createQuery(jpql, ProductRankScore.class).getResultList();
+ }
+
+ @Override
+ @Transactional
+ public void deleteAll() {
+ String jpql = "DELETE FROM ProductRankScore";
+ int deletedCount = entityManager.createQuery(jpql).executeUpdate();
+ log.info("ProductRankScore 전체 삭제 완료: deletedCount={}", deletedCount);
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
deleted file mode 100644
index 3f2e2bcd3..000000000
--- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationWriterTest.java
+++ /dev/null
@@ -1,255 +0,0 @@
-package com.loopers.infrastructure.batch.rank;
-
-import com.loopers.domain.metrics.ProductMetrics;
-import com.loopers.domain.rank.ProductRank;
-import com.loopers.domain.rank.ProductRankRepository;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.springframework.batch.item.Chunk;
-
-import java.time.LocalDate;
-import java.util.ArrayList;
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.*;
-
-/**
- * ProductRankAggregationWriter 테스트.
- */
-@ExtendWith(MockitoExtension.class)
-class ProductRankAggregationWriterTest {
-
- @Mock
- private ProductRankRepository productRankRepository;
-
- @Mock
- private ProductRankAggregationProcessor productRankAggregationProcessor;
-
- @InjectMocks
- private ProductRankAggregationWriter writer;
-
- @DisplayName("Chunk를 정상적으로 처리할 수 있다")
- @Test
- void writesChunk_successfully() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- List items = createProductMetricsList(3);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
- verify(productRankRepository, times(1))
- .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
-
- List savedRanks = ranksCaptor.getValue();
- assertThat(savedRanks).isNotEmpty();
- }
-
- @DisplayName("빈 Chunk는 처리하지 않는다")
- @Test
- void skipsEmptyChunk() throws Exception {
- // arrange
- Chunk chunk = new Chunk<>(new ArrayList<>());
-
- // act
- writer.write(chunk);
-
- // assert
- verify(productRankRepository, never()).saveRanks(any(), any(), any());
- }
-
- @DisplayName("기간 정보가 설정되지 않으면 처리하지 않는다")
- @Test
- void skipsProcessing_whenPeriodNotSet() throws Exception {
- // arrange
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(null);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null);
-
- List items = createProductMetricsList(3);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- verify(productRankRepository, never()).saveRanks(any(), any(), any());
- }
-
- @DisplayName("같은 product_id를 가진 메트릭을 합산한다")
- @Test
- void aggregatesMetricsByProductId() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- // 같은 productId를 가진 여러 메트릭
- List items = new ArrayList<>();
- ProductMetrics metrics1 = new ProductMetrics(1L);
- metrics1.incrementLikeCount();
- metrics1.incrementSalesCount(10);
- items.add(metrics1);
-
- ProductMetrics metrics2 = new ProductMetrics(1L); // 같은 productId
- metrics2.incrementLikeCount();
- metrics2.incrementSalesCount(20);
- items.add(metrics2);
-
- ProductMetrics metrics3 = new ProductMetrics(2L); // 다른 productId
- metrics3.incrementLikeCount();
- items.add(metrics3);
-
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
- verify(productRankRepository, times(1))
- .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
-
- List savedRanks = ranksCaptor.getValue();
- assertThat(savedRanks).hasSize(2); // productId 1과 2
-
- // productId 1의 메트릭이 합산되었는지 확인
- ProductRank rank1 = savedRanks.stream()
- .filter(r -> r.getProductId().equals(1L))
- .findFirst()
- .orElseThrow();
- assertThat(rank1.getLikeCount()).isEqualTo(2L); // 1 + 1
- assertThat(rank1.getSalesCount()).isEqualTo(30L); // 10 + 20
- }
-
- @DisplayName("종합 점수 기준으로 TOP 100을 선정한다")
- @Test
- void selectsTop100ByScore() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- // 150개의 메트릭 생성 (TOP 100만 선택되어야 함)
- List items = createProductMetricsList(150);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
- verify(productRankRepository, times(1))
- .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
-
- List savedRanks = ranksCaptor.getValue();
- assertThat(savedRanks).hasSizeLessThanOrEqualTo(100);
- }
-
- @DisplayName("랭킹을 1부터 시작하여 부여한다")
- @Test
- void assignsRanksStartingFromOne() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- List items = createProductMetricsList(5);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- ArgumentCaptor> ranksCaptor = ArgumentCaptor.forClass(List.class);
- verify(productRankRepository, times(1))
- .saveRanks(eq(periodType), eq(periodStartDate), ranksCaptor.capture());
-
- List savedRanks = ranksCaptor.getValue();
- assertThat(savedRanks).extracting(ProductRank::getRank)
- .containsExactly(1, 2, 3, 4, 5);
- }
-
- @DisplayName("주간 랭킹을 저장한다")
- @Test
- void savesWeeklyRanks() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- List items = createProductMetricsList(3);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- verify(productRankRepository, times(1))
- .saveRanks(eq(ProductRank.PeriodType.WEEKLY), eq(periodStartDate), any());
- }
-
- @DisplayName("월간 랭킹을 저장한다")
- @Test
- void savesMonthlyRanks() throws Exception {
- // arrange
- ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY;
- LocalDate periodStartDate = LocalDate.of(2024, 12, 1);
-
- when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
- when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
-
- List items = createProductMetricsList(3);
- Chunk chunk = new Chunk<>(items);
-
- // act
- writer.write(chunk);
-
- // assert
- verify(productRankRepository, times(1))
- .saveRanks(eq(ProductRank.PeriodType.MONTHLY), eq(periodStartDate), any());
- }
-
- /**
- * 테스트용 ProductMetrics 리스트를 생성합니다.
- *
- * @param count 생성할 항목 수
- * @return ProductMetrics 리스트
- */
- private List createProductMetricsList(int count) {
- List items = new ArrayList<>();
- for (long i = 1; i <= count; i++) {
- ProductMetrics metrics = new ProductMetrics(i);
- // 점수가 높은 순서로 생성 (i가 클수록 점수가 높음)
- metrics.incrementLikeCount();
- metrics.incrementSalesCount((int) (i * 10));
- metrics.incrementViewCount();
- items.add(metrics);
- }
- return items;
- }
-}
-
From 7e91c919b63fa6522cabad8f3272c686a8f5003b Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 2 Jan 2026 02:42:08 +0900
Subject: [PATCH 13/14] =?UTF-8?q?chore:=20db=20=EC=B4=88=EA=B8=B0=ED=99=94?=
=?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D?=
=?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../com/loopers/utils/DatabaseCleanUp.java | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
index 14251dad8..8648e9c8f 100644
--- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
+++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
@@ -38,7 +38,23 @@ public void truncateAllTables() {
if (!tableName.startsWith("`") && !tableName.endsWith("`")) {
tableName = "`" + tableName + "`";
}
- entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
+
+ // 테이블이 존재하는지 확인 후 TRUNCATE 수행
+ try {
+ // 테이블 존재 여부 확인
+ String checkTableSql = "SELECT COUNT(*) FROM information_schema.tables " +
+ "WHERE table_schema = DATABASE() AND table_name = ?";
+ Long count = ((Number) entityManager.createNativeQuery(checkTableSql)
+ .setParameter(1, table.replace("`", ""))
+ .getSingleResult()).longValue();
+
+ if (count > 0) {
+ entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
+ }
+ } catch (Exception e) {
+ // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행
+ // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음)
+ }
}
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
From 6c6d341bec894ce70de3b15b4eb4600989c18e15 Mon Sep 17 00:00:00 2001
From: minor7295
Date: Fri, 2 Jan 2026 02:53:50 +0900
Subject: [PATCH 14/14] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EC=A7=91?=
=?UTF-8?q?=EA=B3=84=EC=9D=98=20=EA=B0=81=20step=EC=97=90=20=EB=8C=80?=
=?UTF-8?q?=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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
---
.../ProductRankCalculationProcessorTest.java | 263 ++++++++++++++++++
...ProductRankScoreAggregationWriterTest.java | 251 +++++++++++++++++
2 files changed, 514 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java
new file mode 100644
index 000000000..cf55ad54d
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java
@@ -0,0 +1,263 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.rank.ProductRank;
+import com.loopers.domain.rank.ProductRankScore;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * ProductRankCalculationProcessor 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductRankCalculationProcessorTest {
+
+ @Mock
+ private ProductRankAggregationProcessor productRankAggregationProcessor;
+
+ private ProductRankCalculationProcessor processor;
+
+ @BeforeEach
+ void setUp() {
+ processor = new ProductRankCalculationProcessor(productRankAggregationProcessor);
+ }
+
+ @DisplayName("랭킹 번호를 1부터 순차적으로 부여한다")
+ @Test
+ void assignsRankSequentially() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ ProductRankScore score1 = createProductRankScore(1L, 10L, 20L, 5L);
+ ProductRankScore score2 = createProductRankScore(2L, 15L, 25L, 8L);
+ ProductRankScore score3 = createProductRankScore(3L, 8L, 15L, 3L);
+
+ // act
+ ProductRank rank1 = processor.process(score1);
+ ProductRank rank2 = processor.process(score2);
+ ProductRank rank3 = processor.process(score3);
+
+ // assert
+ assertThat(rank1).isNotNull();
+ assertThat(rank1.getRank()).isEqualTo(1);
+ assertThat(rank1.getProductId()).isEqualTo(1L);
+
+ assertThat(rank2).isNotNull();
+ assertThat(rank2.getRank()).isEqualTo(2);
+ assertThat(rank2.getProductId()).isEqualTo(2L);
+
+ assertThat(rank3).isNotNull();
+ assertThat(rank3.getRank()).isEqualTo(3);
+ assertThat(rank3.getProductId()).isEqualTo(3L);
+ }
+
+ @DisplayName("TOP 100에 포함되는 경우 ProductRank를 반환한다")
+ @Test
+ void returnsProductRankForTop100() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result.getRank()).isEqualTo(1);
+ assertThat(result.getProductId()).isEqualTo(1L);
+ assertThat(result.getPeriodType()).isEqualTo(periodType);
+ assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate);
+ assertThat(result.getLikeCount()).isEqualTo(10L);
+ assertThat(result.getSalesCount()).isEqualTo(20L);
+ assertThat(result.getViewCount()).isEqualTo(5L);
+ }
+
+ @DisplayName("100번째 처리 후 ThreadLocal이 정리된다")
+ @Test
+ void cleansUpThreadLocalAfter100th() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ // 99개까지 처리
+ for (int i = 1; i <= 99; i++) {
+ ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L);
+ ProductRank result = processor.process(score);
+ assertThat(result).isNotNull();
+ assertThat(result.getRank()).isEqualTo(i);
+ }
+
+ // 100번째 처리 (이 시점에서 rank=100이 되고, rank == TOP_RANK_LIMIT이므로 remove() 호출됨)
+ ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L);
+ ProductRank rank100 = processor.process(score100);
+
+ // assert
+ assertThat(rank100).isNotNull();
+ assertThat(rank100.getRank()).isEqualTo(100);
+
+ // 100번째 처리 후 remove()가 호출되어 ThreadLocal이 정리됨
+ // 실제 배치에서는 100번째 이후는 처리되지 않으므로,
+ // 101번째를 처리하면 currentRank가 0으로 초기화되어 rank=1이 됨
+ // 이는 실제 배치 동작과는 다르지만, ThreadLocal 정리 동작을 검증하기 위한 테스트
+ ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L);
+ ProductRank result = processor.process(score101);
+
+ // remove() 후이므로 currentRank가 0으로 초기화되어 rank=1이 되고,
+ // rank <= 100이므로 ProductRank가 반환됨
+ assertThat(result).isNotNull();
+ assertThat(result.getRank()).isEqualTo(1); // remove() 후 다시 1부터 시작
+ }
+
+ @DisplayName("정확히 100번째는 ProductRank를 반환한다")
+ @Test
+ void returnsProductRankFor100th() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ // 99개까지 처리
+ for (int i = 1; i <= 99; i++) {
+ ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L);
+ processor.process(score);
+ }
+
+ // 100번째 처리
+ ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score100);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result.getRank()).isEqualTo(100);
+ assertThat(result.getProductId()).isEqualTo(100L);
+ }
+
+ @DisplayName("기간 정보가 설정되지 않으면 null을 반환한다")
+ @Test
+ void returnsNullWhenPeriodNotSet() throws Exception {
+ // arrange
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(null);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null);
+
+ ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNull();
+ }
+
+ @DisplayName("기간 시작일이 설정되지 않으면 null을 반환한다")
+ @Test
+ void returnsNullWhenPeriodStartDateNotSet() throws Exception {
+ // arrange
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(ProductRank.PeriodType.WEEKLY);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null);
+
+ ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNull();
+ }
+
+ @DisplayName("주간 기간 정보로 ProductRank를 생성한다")
+ @Test
+ void createsProductRankWithWeeklyPeriod() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY);
+ assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate);
+ }
+
+ @DisplayName("월간 기간 정보로 ProductRank를 생성한다")
+ @Test
+ void createsProductRankWithMonthlyPeriod() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 1);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY);
+ assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate);
+ }
+
+ @DisplayName("ProductRankScore의 메트릭 값을 ProductRank에 전달한다")
+ @Test
+ void transfersMetricsFromScoreToRank() throws Exception {
+ // arrange
+ ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY;
+ LocalDate periodStartDate = LocalDate.of(2024, 12, 9);
+
+ when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType);
+ when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate);
+
+ ProductRankScore score = createProductRankScore(1L, 100L, 200L, 50L);
+
+ // act
+ ProductRank result = processor.process(score);
+
+ // assert
+ assertThat(result).isNotNull();
+ assertThat(result.getLikeCount()).isEqualTo(100L);
+ assertThat(result.getSalesCount()).isEqualTo(200L);
+ assertThat(result.getViewCount()).isEqualTo(50L);
+ }
+
+ /**
+ * 테스트용 ProductRankScore를 생성합니다.
+ */
+ private ProductRankScore createProductRankScore(Long productId, Long likeCount, Long salesCount, Long viewCount) {
+ double score = likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2;
+ return new ProductRankScore(productId, likeCount, salesCount, viewCount, score);
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java
new file mode 100644
index 000000000..5aab9868a
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java
@@ -0,0 +1,251 @@
+package com.loopers.infrastructure.batch.rank;
+
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.rank.ProductRankScore;
+import com.loopers.domain.rank.ProductRankScoreRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.batch.item.Chunk;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+/**
+ * ProductRankScoreAggregationWriter 테스트.
+ */
+@ExtendWith(MockitoExtension.class)
+class ProductRankScoreAggregationWriterTest {
+
+ @Mock
+ private ProductRankScoreRepository productRankScoreRepository;
+
+ @InjectMocks
+ private ProductRankScoreAggregationWriter writer;
+
+ @DisplayName("Chunk 내에서 같은 product_id를 가진 메트릭을 집계한다")
+ @Test
+ void aggregatesMetricsByProductId() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ // 같은 product_id를 가진 메트릭 2개
+ ProductMetrics metrics1 = new ProductMetrics(1L);
+ metrics1.incrementLikeCount();
+ metrics1.incrementSalesCount(10);
+ metrics1.incrementViewCount();
+ items.add(metrics1);
+
+ ProductMetrics metrics2 = new ProductMetrics(1L);
+ metrics2.incrementLikeCount();
+ metrics2.incrementSalesCount(20);
+ metrics2.incrementViewCount();
+ items.add(metrics2);
+
+ // 다른 product_id
+ ProductMetrics metrics3 = new ProductMetrics(2L);
+ metrics3.incrementLikeCount();
+ items.add(metrics3);
+
+ Chunk chunk = new Chunk<>(items);
+
+ when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty());
+ doNothing().when(productRankScoreRepository).saveAll(anyList());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankScoreRepository, times(1)).saveAll(captor.capture());
+
+ List savedScores = captor.getValue();
+ assertThat(savedScores).hasSize(2);
+
+ // product_id=1: 좋아요 2, 판매량 30, 조회수 2
+ ProductRankScore score1 = savedScores.stream()
+ .filter(s -> s.getProductId().equals(1L))
+ .findFirst()
+ .orElseThrow();
+ assertThat(score1.getLikeCount()).isEqualTo(2L);
+ assertThat(score1.getSalesCount()).isEqualTo(30L);
+ assertThat(score1.getViewCount()).isEqualTo(2L);
+
+ // product_id=2: 좋아요 1, 판매량 0, 조회수 0
+ ProductRankScore score2 = savedScores.stream()
+ .filter(s -> s.getProductId().equals(2L))
+ .findFirst()
+ .orElseThrow();
+ assertThat(score2.getLikeCount()).isEqualTo(1L);
+ assertThat(score2.getSalesCount()).isEqualTo(0L);
+ assertThat(score2.getViewCount()).isEqualTo(0L);
+ }
+
+ @DisplayName("점수를 올바른 가중치로 계산한다")
+ @Test
+ void calculatesScoreWithCorrectWeights() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount(); // 1
+ metrics.incrementSalesCount(10); // 10
+ metrics.incrementViewCount(); // 1
+ items.add(metrics);
+
+ Chunk chunk = new Chunk<>(items);
+
+ when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty());
+ doNothing().when(productRankScoreRepository).saveAll(anyList());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankScoreRepository, times(1)).saveAll(captor.capture());
+
+ ProductRankScore savedScore = captor.getValue().get(0);
+ // 점수 = 1 * 0.3 + 10 * 0.5 + 1 * 0.2 = 0.3 + 5.0 + 0.2 = 5.5
+ assertThat(savedScore.getScore()).isEqualTo(5.5);
+ }
+
+ @DisplayName("기존 데이터가 있으면 누적하여 저장한다")
+ @Test
+ void accumulatesWithExistingData() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount(10);
+ metrics.incrementViewCount();
+ items.add(metrics);
+
+ Chunk chunk = new Chunk<>(items);
+
+ // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3
+ ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1);
+ when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.of(existingScore));
+ doNothing().when(productRankScoreRepository).saveAll(anyList());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankScoreRepository, times(1)).saveAll(captor.capture());
+
+ ProductRankScore savedScore = captor.getValue().get(0);
+ // 누적: 좋아요 5+1=6, 판매량 20+10=30, 조회수 3+1=4
+ assertThat(savedScore.getLikeCount()).isEqualTo(6L);
+ assertThat(savedScore.getSalesCount()).isEqualTo(30L);
+ assertThat(savedScore.getViewCount()).isEqualTo(4L);
+ // 점수 = 6 * 0.3 + 30 * 0.5 + 4 * 0.2 = 1.8 + 15.0 + 0.8 = 17.6
+ assertThat(savedScore.getScore()).isEqualTo(17.6);
+ }
+
+ @DisplayName("빈 Chunk는 처리하지 않는다")
+ @Test
+ void skipsEmptyChunk() throws Exception {
+ // arrange
+ Chunk chunk = new Chunk<>(new ArrayList<>());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ verify(productRankScoreRepository, never()).findByProductId(anyLong());
+ verify(productRankScoreRepository, never()).saveAll(anyList());
+ }
+
+ @DisplayName("여러 product_id를 가진 Chunk를 처리한다")
+ @Test
+ void processesMultipleProductIds() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ for (long i = 1; i <= 5; i++) {
+ ProductMetrics metrics = new ProductMetrics(i);
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount((int) i);
+ metrics.incrementViewCount();
+ items.add(metrics);
+ }
+
+ Chunk chunk = new Chunk<>(items);
+
+ when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty());
+ doNothing().when(productRankScoreRepository).saveAll(anyList());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankScoreRepository, times(1)).saveAll(captor.capture());
+
+ List savedScores = captor.getValue();
+ assertThat(savedScores).hasSize(5);
+
+ // 각 product_id별로 저장되었는지 확인
+ for (long i = 1; i <= 5; i++) {
+ long productId = i;
+ ProductRankScore score = savedScores.stream()
+ .filter(s -> s.getProductId().equals(productId))
+ .findFirst()
+ .orElseThrow();
+ assertThat(score.getProductId()).isEqualTo(productId);
+ assertThat(score.getLikeCount()).isEqualTo(1L);
+ assertThat(score.getSalesCount()).isEqualTo(productId);
+ assertThat(score.getViewCount()).isEqualTo(1L);
+ }
+ }
+
+ @DisplayName("기존 데이터가 없으면 새로 생성한다")
+ @Test
+ void createsNewScoreWhenNoExistingData() throws Exception {
+ // arrange
+ List items = new ArrayList<>();
+
+ ProductMetrics metrics = new ProductMetrics(1L);
+ metrics.incrementLikeCount();
+ metrics.incrementSalesCount(10);
+ metrics.incrementViewCount();
+ items.add(metrics);
+
+ Chunk chunk = new Chunk<>(items);
+
+ when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.empty());
+ doNothing().when(productRankScoreRepository).saveAll(anyList());
+
+ // act
+ writer.write(chunk);
+
+ // assert
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankScoreRepository, times(1)).saveAll(captor.capture());
+
+ ProductRankScore savedScore = captor.getValue().get(0);
+ assertThat(savedScore.getProductId()).isEqualTo(1L);
+ assertThat(savedScore.getLikeCount()).isEqualTo(1L);
+ assertThat(savedScore.getSalesCount()).isEqualTo(10L);
+ assertThat(savedScore.getViewCount()).isEqualTo(1L);
+ }
+}
+