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..b6ebf5fc5 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,151 @@ 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<>();
+ long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위)
+
+ 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(
+ currentRank++, // 연속 순위 부여
+ 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..22dfd22c9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java
@@ -0,0 +1,120 @@
+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` (단일 테이블)
+ * - 주간 랭킹: period_type = WEEKLY
+ * - 월간 랭킹: 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..d995ff486
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java
@@ -0,0 +1,65 @@
+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 org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRank Repository 구현체.
+ *
+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다.
+ *
+ */
+@Slf4j
+@Repository
+@Transactional(readOnly = true)
+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;
+ }
+ }
}
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에서 집계 및 조회를 위한 메트릭 엔티티입니다.
+ *
+ *
+ * 도메인 분리 근거:
+ *
+ * - 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
+ * - 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/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..4df1e6311
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -0,0 +1,94 @@
+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 필드를 기준으로 해당 날짜의 데이터만 조회합니다.
+ *
+ *
+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로,
+ * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면:
+ *
+ * - startDateTime: 2024-12-15 00:00:00
+ * - endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
+ *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다.
+ *
+ *
+ * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive)
+ * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive)
+ * @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
+ */
+ org.springframework.data.repository.PagingAndSortingRepository getJpaRepository();
+}
+
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..42a261c97
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java
@@ -0,0 +1,167 @@
+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` (단일 테이블)
+ * - 주간 랭킹: period_type = WEEKLY
+ * - 월간 랭킹: 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 // 월간
+ }
+}
+
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/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..efb09527d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java
@@ -0,0 +1,80 @@
+package com.loopers.domain.rank;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * 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);
+
+ /**
+ * 여러 product_id로 ProductRankScore를 일괄 조회합니다.
+ *
+ * N+1 쿼리 문제를 방지하기 위해 사용합니다.
+ *
+ *
+ * @param productIds 상품 ID 집합
+ * @return ProductRankScore 리스트
+ */
+ List findAllByProductIdIn(Set productIds);
+
+ /**
+ * 모든 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/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;
+ }
+}
+
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..3f58bc891
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java
@@ -0,0 +1,172 @@
+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) {
+ DateRange weekRange = calculateWeeklyRange(targetDate);
+
+ log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}",
+ targetDate, weekRange.startDate(), weekRange.endDate());
+
+ return createReader(weekRange.startDateTime(), weekRange.endDateTime(), "weeklyReader");
+ }
+
+ /**
+ * 주간 범위를 계산합니다.
+ *
+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다.
+ *
+ *
+ * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능)
+ * @return 주간 범위 (시작일, 종료일)
+ */
+ DateRange calculateWeeklyRange(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();
+
+ return new DateRange(weekStart, weekEnd, startDateTime, endDateTime);
+ }
+
+ /**
+ * 월간 집계를 위한 Reader를 생성합니다.
+ *
+ * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다.
+ *
+ *
+ * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능)
+ * @return RepositoryItemReader 인스턴스
+ */
+ public RepositoryItemReader createMonthlyReader(LocalDate targetDate) {
+ DateRange monthRange = calculateMonthlyRange(targetDate);
+
+ log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}",
+ targetDate, monthRange.startDate(), monthRange.endDate());
+
+ return createReader(monthRange.startDateTime(), monthRange.endDateTime(), "monthlyReader");
+ }
+
+ /**
+ * 월간 범위를 계산합니다.
+ *
+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다.
+ *
+ *
+ * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능)
+ * @return 월간 범위 (시작일, 종료일)
+ */
+ DateRange calculateMonthlyRange(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();
+
+ return new DateRange(monthStart, monthEnd, startDateTime, endDateTime);
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 날짜 범위를 담는 레코드.
+ *
+ * 테스트 가능성을 위해 내부 클래스로 정의했습니다.
+ *
+ *
+ * @param startDate 시작일
+ * @param endDate 종료일 (exclusive)
+ * @param startDateTime 시작 시각
+ * @param endDateTime 종료 시각 (exclusive)
+ */
+ record DateRange(
+ LocalDate startDate,
+ LocalDate endDate,
+ LocalDateTime startDateTime,
+ LocalDateTime endDateTime
+ ) {
+ }
+}
+
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..159138dae
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java
@@ -0,0 +1,83 @@
+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.core.configuration.annotation.StepScope;
+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
+@StepScope
+@RequiredArgsConstructor
+public class ProductRankCalculationProcessor implements ItemProcessor {
+
+ private final ProductRankAggregationProcessor productRankAggregationProcessor;
+ private int currentRank = 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;
+
+ // 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()
+ );
+
+ 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..679d1d823
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java
@@ -0,0 +1,74 @@
+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.core.configuration.annotation.StepScope;
+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
+@StepScope
+@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..40530a10a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java
@@ -0,0 +1,84 @@
+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.core.configuration.annotation.StepScope;
+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
+@StepScope
+@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
new file mode 100644
index 000000000..875bd3519
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java
@@ -0,0 +1,261 @@
+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에 저장합니다.
+ *
+ *
+ * 구현 의도:
+ *
+ * - Step 1 (집계 로직 계산): 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
+ * - Step 2 (랭킹 로직 실행): 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여
+ * - 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 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 scoreAggregationStep,
+ Step rankingCalculationStep
+ ) {
+ return new JobBuilder("productRankAggregationJob", jobRepository)
+ .start(scoreAggregationStep) // Step 1 먼저 실행
+ .next(rankingCalculationStep) // Step 1 완료 후 Step 2 실행
+ .build();
+ }
+
+ /**
+ * Step 1: 집계 로직 계산 Step을 생성합니다.
+ *
+ * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다.
+ *
+ *
+ * Chunk-Oriented Processing을 사용하여:
+ *
+ * - Reader: 특정 기간의 product_metrics를 페이징하여 읽기
+ * - Processor: Pass-through (필터링 필요 시 추가 가능)
+ * - Writer: product_id별로 점수 집계하여 ProductRankScore 테이블에 저장
+ *
+ *
+ *
+ * @param productRankReader ProductRank Reader (StepScope Bean)
+ * @param productRankScoreWriter ProductRankScore Writer
+ * @return 집계 로직 계산 Step
+ */
+ @Bean
+ public Step scoreAggregationStep(
+ ItemReader productRankReader,
+ ItemWriter productRankScoreWriter
+ ) {
+ return new StepBuilder("scoreAggregationStep", jobRepository)
+ .chunk(100, transactionManager) // Chunk 크기: 100
+ .reader(productRankReader)
+ .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();
+ }
+
+ /**
+ * 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
+ ) {
+ if (periodType == null || periodType.isEmpty()) {
+ throw new IllegalArgumentException("periodType 파라미터는 필수입니다. (WEEKLY 또는 MONTHLY)");
+ }
+
+ 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);
+ }
+ }
+
+ /**
+ * 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
+ */
+ @Bean
+ public ItemProcessor productRankCalculationProcessor() {
+ return productRankCalculationProcessor;
+ }
+
+ /**
+ * Step 2용 ProductRank 계산 Writer를 주입받습니다.
+ *
+ * @return ProductRank 계산 Writer
+ */
+ @Bean
+ public ItemWriter productRankCalculationWriter() {
+ return productRankCalculationWriter;
+ }
+
+ /**
+ * 날짜 문자열을 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();
+ }
+ }
+}
+
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..59a8da624
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java
@@ -0,0 +1,178 @@
+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.Set;
+import java.util.function.Function;
+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()
+ )
+ )
+ ));
+
+ // Chunk 내 모든 productId를 한 번에 조회
+ Set productIds = chunkAggregatedMap.keySet();
+ Map existingScores = productRankScoreRepository
+ .findAllByProductIdIn(productIds)
+ .stream()
+ .collect(Collectors.toMap(ProductRankScore::getProductId, Function.identity()));
+
+ // 기존 데이터와 누적하여 ProductRankScore 생성
+ List scores = chunkAggregatedMap.entrySet().stream()
+ .map(entry -> {
+ Long productId = entry.getKey();
+ AggregatedMetrics chunkAggregated = entry.getValue();
+
+ // 기존 데이터 조회 (일괄 조회 결과에서)
+ ProductRankScore existing = existingScores.get(productId);
+
+ // 기존 데이터와 누적
+ Long totalLikeCount = chunkAggregated.getLikeCount();
+ Long totalSalesCount = chunkAggregated.getSalesCount();
+ Long totalViewCount = chunkAggregated.getViewCount();
+
+ if (existing != null) {
+ totalLikeCount += existing.getLikeCount();
+ totalSalesCount += existing.getSalesCount();
+ totalViewCount += existing.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/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
new file mode 100644
index 000000000..ff10e63c2
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
@@ -0,0 +1,67 @@
+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 필드를 기준으로 해당 날짜의 데이터만 조회합니다.
+ *
+ *
+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로,
+ * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면:
+ *
+ * - startDateTime: 2024-12-15 00:00:00
+ * - endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
+ *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다.
+ *
+ *
+ * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive)
+ * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive)
+ * @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..51d974de5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
@@ -0,0 +1,72 @@
+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
+ public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() {
+ return productMetricsJpaRepository;
+ }
+}
+
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);
+ }
+}
+
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..3037e8f99
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java
@@ -0,0 +1,113 @@
+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;
+import java.util.Set;
+
+/**
+ * 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 findAllByProductIdIn(Set productIds) {
+ if (productIds == null || productIds.isEmpty()) {
+ return List.of();
+ }
+
+ String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId IN :productIds";
+ return entityManager.createQuery(jpql, ProductRankScore.class)
+ .setParameter("productIds", productIds)
+ .getResultList();
+ }
+
+ @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/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/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(); // 더 크면 업데이트
+ }
+}
+
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 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;
+ }
+}
+
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..286108683
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java
@@ -0,0 +1,168 @@
+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); // 일요일
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ ProductRankAggregationReader.DateRange range = reader.calculateWeeklyRange(targetDate);
+
+ // assert
+ // 2024-12-15(일) -> 2024-12-09(월)이 시작일
+ assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일
+ assertThat(range.endDate()).isEqualTo(LocalDate.of(2024, 12, 16)); // 다음 주 월요일
+ assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 9).atStartOfDay());
+ assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2024, 12, 16).atStartOfDay());
+ }
+
+ @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);
+
+ ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository);
+
+ // act
+ ProductRankAggregationReader.DateRange range = reader.calculateMonthlyRange(targetDate);
+
+ // assert
+ // 2024-12-15 -> 2024-12-01이 시작일
+ assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 1일
+ assertThat(range.endDate()).isEqualTo(LocalDate.of(2025, 1, 1)); // 다음 달 1일
+ assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 1).atStartOfDay());
+ assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2025, 1, 1).atStartOfDay());
+ }
+
+ @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다")
+ @Test
+ void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() {
+ // arrange
+ 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
+ ProductRankAggregationReader.DateRange mondayRange = reader.calculateWeeklyRange(monday);
+ ProductRankAggregationReader.DateRange wednesdayRange = reader.calculateWeeklyRange(wednesday);
+ ProductRankAggregationReader.DateRange sundayRange = reader.calculateWeeklyRange(sunday);
+
+ // assert
+ // 모두 같은 주의 월요일부터 시작해야 함
+ LocalDate expectedStart = LocalDate.of(2024, 12, 9); // 월요일
+ LocalDate expectedEnd = LocalDate.of(2024, 12, 16); // 다음 주 월요일
+
+ assertThat(mondayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(mondayRange.endDate()).isEqualTo(expectedEnd);
+
+ assertThat(wednesdayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(wednesdayRange.endDate()).isEqualTo(expectedEnd);
+
+ assertThat(sundayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(sundayRange.endDate()).isEqualTo(expectedEnd);
+ }
+
+ @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다")
+ @Test
+ void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() {
+ // arrange
+ 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
+ ProductRankAggregationReader.DateRange firstDayRange = reader.calculateMonthlyRange(firstDay);
+ ProductRankAggregationReader.DateRange midDayRange = reader.calculateMonthlyRange(midDay);
+ ProductRankAggregationReader.DateRange lastDayRange = reader.calculateMonthlyRange(lastDay);
+
+ // assert
+ // 모두 같은 월의 1일부터 시작해야 함
+ LocalDate expectedStart = LocalDate.of(2024, 12, 1); // 1일
+ LocalDate expectedEnd = LocalDate.of(2025, 1, 1); // 다음 달 1일
+
+ assertThat(firstDayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(firstDayRange.endDate()).isEqualTo(expectedEnd);
+
+ assertThat(midDayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(midDayRange.endDate()).isEqualTo(expectedEnd);
+
+ assertThat(lastDayRange.startDate()).isEqualTo(expectedStart);
+ assertThat(lastDayRange.endDate()).isEqualTo(expectedEnd);
+ }
+}
+
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..2bd675457
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java
@@ -0,0 +1,250 @@
+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("101번째 이후는 null을 반환한다 (TOP 100 초과)")
+ @Test
+ void returnsNullAfter100th() 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);
+
+ // 100개까지 처리
+ for (int i = 1; i <= 100; i++) {
+ ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L);
+ ProductRank result = processor.process(score);
+ assertThat(result).isNotNull();
+ assertThat(result.getRank()).isEqualTo(i);
+ }
+
+ // 101번째 처리 (rank > TOP_RANK_LIMIT이므로 null 반환)
+ ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L);
+ ProductRank result = processor.process(score101);
+
+ // assert
+ assertThat(result).isNull(); // TOP 100 초과이므로 null 반환
+ }
+
+ @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..626d3ee2f
--- /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.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anySet;
+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.findAllByProductIdIn(anySet())).thenReturn(List.of());
+ 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.findAllByProductIdIn(anySet())).thenReturn(List.of());
+ 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.findAllByProductIdIn(anySet())).thenReturn(List.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()).findAllByProductIdIn(anySet());
+ 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.findAllByProductIdIn(anySet())).thenReturn(List.of());
+ 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.findAllByProductIdIn(anySet())).thenReturn(List.of());
+ 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);
+ }
+}
+
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();
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",