From d4cecccb34b505a85fbe617806b98527a10e29d4 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Fri, 2 Jan 2026 03:03:57 +0900 Subject: [PATCH 1/2] Feature/batch (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: batch 처리 모듇 분리 * feat: batch 모듈에 ProductMetrics 도메인 추가 * feat: ProudctMetrics의 Repository 추가 * test: Product Metrics 배치 작업에 대한 테스트 코드 추가 * feat: ProductMetrics 배치 작업 구현 * test: Product Rank에 대한 테스트 코드 추가 * feat: Product Rank 도메인 구현 * feat: Product Rank Repository 추가 * test: Product Rank 배치에 대한 테스트 코드 추가 * feat: Product Rank 배치 작업 추가 * feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가 * refractor: 랭킹 집계 로직을 여러 step으로 분리함 * chore: db 초기화 로직에서 발생하는 오류 수정 * test: 랭킹 집계의 각 step에 대한 테스트 코드 추가 --- apps/commerce-api/build.gradle.kts | 3 - .../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 ++- .../src/main/resources/application.yml | 5 - apps/commerce-batch/build.gradle.kts | 22 ++ .../java/com/loopers/BatchApplication.java | 34 +++ .../domain/metrics/ProductMetrics.java | 134 +++++++++ .../metrics/ProductMetricsRepository.java | 86 ++++++ .../com/loopers/domain/rank/ProductRank.java | 166 +++++++++++ .../domain/rank/ProductRankRepository.java | 59 ++++ .../loopers/domain/rank/ProductRankScore.java | 141 ++++++++++ .../rank/ProductRankScoreRepository.java | 68 +++++ .../metrics/ProductMetricsItemProcessor.java | 45 +++ .../metrics/ProductMetricsItemReader.java | 111 ++++++++ .../metrics/ProductMetricsItemWriter.java | 58 ++++ .../metrics/ProductMetricsJobConfig.java | 148 ++++++++++ .../rank/ProductRankAggregationProcessor.java | 74 +++++ .../rank/ProductRankAggregationReader.java | 123 ++++++++ .../rank/ProductRankCalculationProcessor.java | 87 ++++++ .../rank/ProductRankCalculationReader.java | 72 +++++ .../rank/ProductRankCalculationWriter.java | 82 ++++++ .../batch/rank/ProductRankJobConfig.java | 257 +++++++++++++++++ .../ProductRankScoreAggregationWriter.java | 170 +++++++++++ .../metrics/ProductMetricsJpaRepository.java | 58 ++++ .../metrics/ProductMetricsRepositoryImpl.java | 73 +++++ .../rank/ProductRankRepositoryImpl.java | 95 +++++++ .../rank/ProductRankScoreRepositoryImpl.java | 100 +++++++ .../src/main/resources/application.yml | 43 +++ .../domain/metrics/ProductMetricsTest.java | 217 +++++++++++++++ .../loopers/domain/rank/ProductRankTest.java | 235 ++++++++++++++++ .../ProductMetricsItemProcessorTest.java | 87 ++++++ .../metrics/ProductMetricsItemReaderTest.java | 134 +++++++++ .../metrics/ProductMetricsItemWriterTest.java | 118 ++++++++ .../ProductRankAggregationProcessorTest.java | 121 ++++++++ .../ProductRankAggregationReaderTest.java | 152 ++++++++++ .../ProductRankCalculationProcessorTest.java | 263 ++++++++++++++++++ ...ProductRankScoreAggregationWriterTest.java | 251 +++++++++++++++++ .../com/loopers/utils/DatabaseCleanUp.java | 18 +- settings.gradle.kts | 1 + 42 files changed, 4342 insertions(+), 11 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 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/java/com/loopers/domain/metrics/ProductMetrics.java 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/domain/rank/ProductRank.java 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/domain/rank/ProductRankScore.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java 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 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/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/ProductRankJobConfig.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/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java 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 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/ProductRankCalculationProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java 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/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; /** * 랭킹을 조회합니다 (페이징). *
+ * 기간별(일간/주간/월간) 랭킹을 조회합니다. + *
+ *+ * 기간별 조회 방식: + *
+ * Graceful Degradation (DAILY만 적용): + *
* 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+ * 가중치: + *
+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *
+ *+ * Materialized View 설계: + *
+ * 인덱스 전략: + *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *
+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *
+ */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public List- * 날짜별 랭킹을 페이징하여 조회합니다. + * 기간별(일간/주간/월간) 랭킹을 페이징하여 조회합니다. + *
+ *+ * 기간 타입: + *
+ * 파싱 실패 시 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; + } + } } 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/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에서 집계 및 조회를 위한 메트릭 엔티티입니다. + *
+ *+ * 도메인 분리 근거: + *
+ * 모듈별 독립성: + *
+ * 이벤트의 `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/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를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *
+ *+ * 도메인 분리 근거: + *
+ * 배치 전용 메서드: + *
+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *
+ * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) + * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + Page+ * RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로, + * 기술적 제약으로 인해 JPA Repository에 대한 접근을 제공합니다. + *
+ *+ * 주의: 이 메서드는 Spring Batch의 기술적 요구사항으로 인해 제공됩니다. + * 일반적인 비즈니스 로직에서는 이 메서드를 사용하지 않고, + * 위의 도메인 메서드들을 사용해야 합니다. + *
+ * + * @return PagingAndSortingRepository를 구현한 JPA Repository + */ + @SuppressWarnings("rawtypes") + org.springframework.data.repository.PagingAndSortingRepository+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *
+ *+ * Materialized View 설계: + *
+ * 인덱스 전략: + *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *
+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 저장합니다. + *+ * 기존 데이터가 있으면 삭제 후 새로 저장합니다 (UPSERT 방식). + *
+ * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param ranks 저장할 랭킹 리스트 (TOP 100) + */ + void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List+ * Step 1 (집계 로직 계산)에서 사용하는 임시 테이블입니다. + * product_id별로 점수를 집계하여 저장하며, 랭킹 번호는 저장하지 않습니다. + *
+ *+ * 사용 목적: + *
+ * 인덱스 전략: + *
+ * 가중치: + *
+ * 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+ * Step 2에서 TOP 100 선정을 위해 사용합니다. + *
+ * + * @param limit 조회할 최대 개수 (기본: 전체) + * @return ProductRankScore 리스트 (점수 내림차순) + */ + List+ * Step 2 완료 후 임시 테이블을 정리하기 위해 사용합니다. + *
+ */ + void deleteAll(); +} + 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. + *+ * 현재는 데이터를 그대로 전달하지만, 향후 집계 로직을 추가할 수 있습니다. + *
+ *+ * 구현 의도: + *
+ * 현재는 데이터를 그대로 전달하지만, 필요시 변환/필터링 로직을 추가할 수 있습니다. + *
+ * + * @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 데이터를 페이징하여 읽습니다. + *
+ *+ * 구현 의도: + *
+ * DIP 준수: + *
+ * Job 파라미터에서 날짜를 받아 해당 날짜의 데이터만 조회합니다. + *
+ * + * @param targetDate 조회할 날짜 (yyyyMMdd 형식) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader+ * 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에 저장하는 로직을 추가할 수 있습니다. + *
+ *+ * 구현 의도: + *
+ * 현재는 로깅만 수행하며, 향후 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 크기 선택 근거: + *
+ * Job 파라미터: + *
+ * 실행 예시: + *
+ * 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을 사용하여: + *
+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *
+ * + * @param targetDate 조회할 날짜 (Job 파라미터에서 주입) + * @return ProductMetrics Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader+ * 기간 정보를 관리하고 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를 읽습니다. + *
+ *+ * 구현 의도: + *
+ * 해당 주의 월요일부터 일요일까지의 ProductMetrics를 조회합니다. + *
+ * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader+ * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다. + *
+ * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * 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 테이블에서 점수 내림차순으로 모든 데이터를 읽습니다. + *
+ *+ * 구현 의도: + *
+ * 첫 호출 시 모든 데이터를 조회하고, 이후 Iterator를 통해 하나씩 반환합니다. + *
+ * + * @return ProductRankScore (더 이상 없으면 null) + * @throws UnexpectedInputException 예상치 못한 입력 오류 + * @throws ParseException 파싱 오류 + * @throws NonTransientResourceException 일시적이지 않은 리소스 오류 + */ + @Override + public ProductRankScore read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException { + if (!initialized) { + // 첫 호출 시 모든 데이터를 점수 내림차순으로 조회 + List+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * 랭킹 번호가 부여된 ProductRank를 Materialized View에 저장합니다. + *
+ *+ * 구현 의도: + *
+ * 모든 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 new file mode 100644 index 000000000..a8c06a0e5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -0,0 +1,257 @@ +package com.loopers.infrastructure.batch.rank; + +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; +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에 저장합니다. + *
+ *+ * 구현 의도: + *
+ * Job 파라미터: + *
+ * 실행 예시: + *
+ * // 주간 집계 + * 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 ProductRankScoreAggregationWriter productRankScoreAggregationWriter; + private final ProductRankCalculationReader productRankCalculationReader; + private final ProductRankCalculationProcessor productRankCalculationProcessor; + private final ProductRankCalculationWriter productRankCalculationWriter; + + /** + * ProductRank 집계 Job을 생성합니다. + *
+ * 2-Step 구조: + *
+ * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다. + *
+ *+ * Chunk-Oriented Processing을 사용하여: + *
+ * 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여하여 Materialized View에 저장합니다. + *
+ *+ * Chunk-Oriented Processing을 사용하여: + *
+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *
+ * + * @param periodType 기간 타입 (Job 파라미터에서 주입) + * @param targetDate 기준 날짜 (Job 파라미터에서 주입) + * @return ProductRank Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader+ * Step 1 (집계 로직 계산 Step)에서 사용합니다. + * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고, + * 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+ * 가중치: + *
+ * 상품 메트릭 집계 데이터를 관리합니다. + * commerce-batch 전용 Repository입니다. + *
+ *+ * 모듈별 독립성: + *
+ * 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+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *
+ *+ * 배치 전용 구현: + *
+ * Materialized View에 저장된 상품 랭킹 데이터를 관리합니다. + *
+ */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *
+ */ +@Slf4j +@Repository +public class ProductRankScoreRepositoryImpl implements ProductRankScoreRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void save(ProductRankScore score) { + Optional+ * 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(); // 더 크면 업데이트 + } +} + 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); + } +} + 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* Materialized View 설계: *
+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *
* Materialized View 설계: *
+ * N+1 쿼리 문제를 방지하기 위해 사용합니다. + *
+ * + * @param productIds 상품 ID 집합 + * @return ProductRankScore 리스트 + */ + List
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
index 449cb18d2..3f58bc891 100644
--- 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
@@ -50,6 +50,24 @@ public class ProductRankAggregationReader {
* @return RepositoryItemReader 인스턴스
*/
public RepositoryItemReader
+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다.
+ *
+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다.
+ *
+ * 테스트 가능성을 위해 내부 클래스로 정의했습니다.
+ *
+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로,
+ * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면:
+ *
+ *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다.
+ *