rankings = response.getBody().data().rankings();
+ assertAll(
+ () -> assertThat(rankings).hasSize(1),
+ () -> assertThat(rankings.get(0).rank()).isEqualTo(3),
+ () -> assertThat(rankings.get(0).productId()).isEqualTo(productId3),
+ () -> assertThat(response.getBody().data().page()).isEqualTo(1),
+ () -> assertThat(response.getBody().data().size()).isEqualTo(2)
+ );
+ }
+ }
+
+ @DisplayName("해당 date에 데이터가 없으면, 빈 배열을 반환한다.")
+ @Test
+ void returnsEmptyRankings_whenNoData() {
+ // act
+ var response = getMonthlyRankings(testRestTemplate, "date=" + DATE);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody().data().rankings()).isEmpty()
+ );
+ }
+ }
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java
new file mode 100644
index 0000000000..3984e6664e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java
@@ -0,0 +1,63 @@
+package com.loopers.batch.job.ranking;
+
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.configuration.annotation.JobScope;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet;
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.listener.StepMonitorListener;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 월간 랭킹 배치 Job 설정.
+ *
+ * {@code spring.batch.job.name=monthlyRankingJob}으로 실행하며,
+ * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 30일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_monthly}
+ * 테이블에 upsert한다.
+ *
+ * 실행 예시:
+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ * --args="--spring.batch.job.name=monthlyRankingJob date=20260414"
+ *
+ */
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
+@RequiredArgsConstructor
+@Configuration
+public class MonthlyRankingJobConfig {
+
+ public static final String JOB_NAME = "monthlyRankingJob";
+ private static final String STEP_NAME = "monthlyRankingStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final JobListener jobListener;
+ private final StepMonitorListener stepMonitorListener;
+ private final MonthlyRankingTasklet monthlyRankingTasklet;
+
+ @Bean(JOB_NAME)
+ public Job monthlyRankingJob() {
+ return new JobBuilder(JOB_NAME, jobRepository)
+ .start(monthlyRankingStep())
+ .listener(jobListener)
+ .build();
+ }
+
+ @JobScope
+ @Bean(STEP_NAME)
+ public Step monthlyRankingStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(monthlyRankingTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
new file mode 100644
index 0000000000..4304c14e82
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
@@ -0,0 +1,63 @@
+package com.loopers.batch.job.ranking;
+
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.configuration.annotation.JobScope;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet;
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.listener.StepMonitorListener;
+
+import lombok.RequiredArgsConstructor;
+
+/**
+ * 주간 랭킹 배치 Job 설정.
+ *
+ * {@code spring.batch.job.name=weeklyRankingJob}으로 실행하며,
+ * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 7일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_weekly} 테이블에
+ * upsert한다.
+ *
+ * 실행 예시:
+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ * --args="--spring.batch.job.name=weeklyRankingJob date=20260413"
+ *
+ */
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME)
+@RequiredArgsConstructor
+@Configuration
+public class WeeklyRankingJobConfig {
+
+ public static final String JOB_NAME = "weeklyRankingJob";
+ private static final String STEP_NAME = "weeklyRankingStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final JobListener jobListener;
+ private final StepMonitorListener stepMonitorListener;
+ private final WeeklyRankingTasklet weeklyRankingTasklet;
+
+ @Bean(JOB_NAME)
+ public Job weeklyRankingJob() {
+ return new JobBuilder(JOB_NAME, jobRepository)
+ .start(weeklyRankingStep())
+ .listener(jobListener)
+ .build();
+ }
+
+ @JobScope
+ @Bean(STEP_NAME)
+ public Step weeklyRankingStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(weeklyRankingTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java
new file mode 100644
index 0000000000..4159e06a1f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java
@@ -0,0 +1,86 @@
+package com.loopers.batch.job.ranking.step;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import com.loopers.batch.job.ranking.MonthlyRankingJobConfig;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.ProductScoreProjection;
+import com.loopers.domain.ranking.ProductRankingMonthly;
+import com.loopers.domain.ranking.ProductRankingMonthlyRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 월간 랭킹 집계 Tasklet.
+ *
+ * JobParameters의 {@code date}(yyyyMMdd)를 기준으로
+ * 직전 30일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_monthly}에 upsert한다.
+ *
+ * 스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}
+ *
+ * 집계 범위 예시 (date=20260414):
+ *
+ * start: 2026-03-15
+ * end: 2026-04-13
+ *
+ */
+@Slf4j
+@StepScope
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
+@RequiredArgsConstructor
+@Component
+public class MonthlyRankingTasklet implements Tasklet {
+
+ private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final int LIMIT = 100;
+
+ private final ProductMetricsRepository productMetricsRepository;
+ private final ProductRankingMonthlyRepository productRankingMonthlyRepository;
+
+ @Value("#{jobParameters['date']}")
+ private String date;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ if (Objects.isNull(date)) {
+ throw new IllegalArgumentException("JobParameter 'date' is required");
+ }
+
+ LocalDate baseDate;
+ try {
+ baseDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e);
+ }
+ LocalDate start = baseDate.minusDays(30);
+ LocalDate end = baseDate.minusDays(1);
+
+ log.info("월간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end);
+
+ List topScores = productMetricsRepository.findTopScores(start, end, LIMIT);
+ log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size());
+
+ List rankings = topScores.stream()
+ .map(p -> ProductRankingMonthly.create(p.productId(), baseDate, p.score()))
+ .toList();
+
+ productRankingMonthlyRepository.saveAll(rankings);
+ log.info("월간 랭킹 집계 완료 — {}개 상품 저장", rankings.size());
+
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java
new file mode 100644
index 0000000000..419865cde7
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java
@@ -0,0 +1,86 @@
+package com.loopers.batch.job.ranking.step;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import com.loopers.batch.job.ranking.WeeklyRankingJobConfig;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.ProductScoreProjection;
+import com.loopers.domain.ranking.ProductRankingWeekly;
+import com.loopers.domain.ranking.ProductRankingWeeklyRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * 주간 랭킹 집계 Tasklet.
+ *
+ * JobParameters의 {@code date}(yyyyMMdd)를 기준으로
+ * 직전 7일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_weekly}에 upsert한다.
+ *
+ * 스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}
+ *
+ * 집계 범위 예시 (date=20260413):
+ *
+ * start: 2026-04-06
+ * end: 2026-04-12
+ *
+ */
+@Slf4j
+@StepScope
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME)
+@RequiredArgsConstructor
+@Component
+public class WeeklyRankingTasklet implements Tasklet {
+
+ private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final int LIMIT = 100;
+
+ private final ProductMetricsRepository productMetricsRepository;
+ private final ProductRankingWeeklyRepository productRankingWeeklyRepository;
+
+ @Value("#{jobParameters['date']}")
+ private String date;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ if (Objects.isNull(date)) {
+ throw new IllegalArgumentException("JobParameter 'date' is required");
+ }
+
+ LocalDate baseDate;
+ try {
+ baseDate = LocalDate.parse(date, DATE_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e);
+ }
+ LocalDate start = baseDate.minusDays(7);
+ LocalDate end = baseDate.minusDays(1);
+
+ log.info("주간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end);
+
+ List topScores = productMetricsRepository.findTopScores(start, end, LIMIT);
+ log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size());
+
+ List rankings = topScores.stream()
+ .map(p -> ProductRankingWeekly.create(p.productId(), baseDate, p.score()))
+ .toList();
+
+ productRankingWeeklyRepository.saveAll(rankings);
+ log.info("주간 랭킹 집계 완료 — {}개 상품 저장", rankings.size());
+
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
new file mode 100644
index 0000000000..294f6a3ca7
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.metrics;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "product_metrics", uniqueConstraints = {
+ @UniqueConstraint(columnNames = {"product_id", "metric_date"})
+}, indexes = {
+ @Index(name = "idx_metric_date", columnList = "metric_date")
+})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductMetrics {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private Long productId;
+
+ @Column(nullable = false)
+ private LocalDate metricDate;
+
+ @Column(nullable = false)
+ private Long viewCount;
+
+ @Column(nullable = false)
+ private Long likeCount;
+
+ @Column(nullable = false)
+ private Long orderCount;
+}
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 0000000000..f12be63b13
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
@@ -0,0 +1,9 @@
+package com.loopers.domain.metrics;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public interface ProductMetricsRepository {
+
+ List findTopScores(LocalDate start, LocalDate end, int limit);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java
new file mode 100644
index 0000000000..e3138be515
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java
@@ -0,0 +1,4 @@
+package com.loopers.domain.metrics;
+
+public record ProductScoreProjection(Long productId, Double score) {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java
new file mode 100644
index 0000000000..34109ac3ea
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java
@@ -0,0 +1,48 @@
+package com.loopers.domain.ranking;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "mv_product_rank_monthly", uniqueConstraints = {
+ @UniqueConstraint(columnNames = {"product_id", "score_date"})
+}, indexes = {
+ @Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
+})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductRankingMonthly {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private Long productId;
+
+ @Column(nullable = false)
+ private LocalDate scoreDate;
+
+ @Column(nullable = false)
+ private Double score;
+
+ public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) {
+ ProductRankingMonthly ranking = new ProductRankingMonthly();
+ ranking.productId = productId;
+ ranking.scoreDate = scoreDate;
+ ranking.score = score;
+ return ranking;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java
new file mode 100644
index 0000000000..b3690375eb
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java
@@ -0,0 +1,23 @@
+package com.loopers.domain.ranking;
+
+import java.util.List;
+
+/**
+ * 월간 랭킹 저장소.
+ *
+ * 배치가 집계한 월간 랭킹 스코어를 저장한다.
+ * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).
+ */
+public interface ProductRankingMonthlyRepository {
+
+ /**
+ * 월간 랭킹 스코어를 일괄 저장한다.
+ *
+ * MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여
+ * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고,
+ * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.
+ *
+ * @param rankings 저장할 월간 랭킹 목록
+ */
+ void saveAll(List rankings);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java
new file mode 100644
index 0000000000..fb179b6d00
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java
@@ -0,0 +1,48 @@
+package com.loopers.domain.ranking;
+
+import java.time.LocalDate;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "mv_product_rank_weekly", uniqueConstraints = {
+ @UniqueConstraint(columnNames = {"product_id", "score_date"})
+}, indexes = {
+ @Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
+})
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class ProductRankingWeekly {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private Long productId;
+
+ @Column(nullable = false)
+ private LocalDate scoreDate;
+
+ @Column(nullable = false)
+ private Double score;
+
+ public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) {
+ ProductRankingWeekly ranking = new ProductRankingWeekly();
+ ranking.productId = productId;
+ ranking.scoreDate = scoreDate;
+ ranking.score = score;
+ return ranking;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java
new file mode 100644
index 0000000000..c76a698159
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java
@@ -0,0 +1,23 @@
+package com.loopers.domain.ranking;
+
+import java.util.List;
+
+/**
+ * 주간 랭킹 저장소.
+ *
+ * 배치가 집계한 주간 랭킹 스코어를 저장한다.
+ * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).
+ */
+public interface ProductRankingWeeklyRepository {
+
+ /**
+ * 주간 랭킹 스코어를 일괄 저장한다.
+ *
+ * MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여
+ * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고,
+ * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.
+ *
+ * @param rankings 저장할 주간 랭킹 목록
+ */
+ void saveAll(List rankings);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java
new file mode 100644
index 0000000000..97665f0830
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.metrics.persistence;
+
+import java.time.LocalDate;
+import java.util.List;
+
+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 com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.metrics.ProductScoreProjection;
+
+public interface ProductMetricsJpaRepository extends JpaRepository {
+
+ @Query("SELECT new com.loopers.domain.metrics.ProductScoreProjection("
+ + "m.productId, "
+ + "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) "
+ + "FROM ProductMetrics m "
+ + "WHERE m.metricDate BETWEEN :start AND :end "
+ + "GROUP BY m.productId "
+ + "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC")
+ List findTopScores(
+ @Param("start") LocalDate start,
+ @Param("end") LocalDate end,
+ Pageable pageable
+ );
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java
new file mode 100644
index 0000000000..61e6b856c2
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java
@@ -0,0 +1,24 @@
+package com.loopers.infrastructure.metrics.persistence;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Repository;
+
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.ProductScoreProjection;
+
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class ProductMetricsRepositoryImpl implements ProductMetricsRepository {
+
+ private final ProductMetricsJpaRepository productMetricsJpaRepository;
+
+ @Override
+ public List findTopScores(LocalDate start, LocalDate end, int limit) {
+ return productMetricsJpaRepository.findTopScores(start, end, PageRequest.of(0, limit));
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java
new file mode 100644
index 0000000000..2aeec6e9f5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java
@@ -0,0 +1,27 @@
+package com.loopers.infrastructure.ranking.persistence;
+
+import java.time.LocalDate;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.loopers.domain.ranking.ProductRankingMonthly;
+
+public interface ProductRankingMonthlyJpaRepository extends JpaRepository {
+
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query(
+ value = "INSERT INTO mv_product_rank_monthly (product_id, score_date, score) "
+ + "VALUES (:productId, :scoreDate, :score) "
+ + "ON DUPLICATE KEY UPDATE "
+ + "score = :score",
+ nativeQuery = true
+ )
+ void upsert(
+ @Param("productId") Long productId,
+ @Param("scoreDate") LocalDate scoreDate,
+ @Param("score") Double score
+ );
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java
new file mode 100644
index 0000000000..3b2d656a9e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java
@@ -0,0 +1,26 @@
+package com.loopers.infrastructure.ranking.persistence;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.loopers.domain.ranking.ProductRankingMonthly;
+import com.loopers.domain.ranking.ProductRankingMonthlyRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class ProductRankingMonthlyRepositoryImpl implements ProductRankingMonthlyRepository {
+
+ private final ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository;
+
+ @Override
+ @Transactional
+ public void saveAll(List rankings) {
+ rankings.forEach(r ->
+ productRankingMonthlyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore())
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java
new file mode 100644
index 0000000000..159e55f090
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java
@@ -0,0 +1,27 @@
+package com.loopers.infrastructure.ranking.persistence;
+
+import java.time.LocalDate;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.loopers.domain.ranking.ProductRankingWeekly;
+
+public interface ProductRankingWeeklyJpaRepository extends JpaRepository {
+
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query(
+ value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) "
+ + "VALUES (:productId, :scoreDate, :score) "
+ + "ON DUPLICATE KEY UPDATE "
+ + "score = :score",
+ nativeQuery = true
+ )
+ void upsert(
+ @Param("productId") Long productId,
+ @Param("scoreDate") LocalDate scoreDate,
+ @Param("score") Double score
+ );
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java
new file mode 100644
index 0000000000..7b36967bdb
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java
@@ -0,0 +1,26 @@
+package com.loopers.infrastructure.ranking.persistence;
+
+import java.util.List;
+
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.loopers.domain.ranking.ProductRankingWeekly;
+import com.loopers.domain.ranking.ProductRankingWeeklyRepository;
+
+import lombok.RequiredArgsConstructor;
+
+@Repository
+@RequiredArgsConstructor
+public class ProductRankingWeeklyRepositoryImpl implements ProductRankingWeeklyRepository {
+
+ private final ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository;
+
+ @Override
+ @Transactional
+ public void saveAll(List rankings) {
+ rankings.forEach(r ->
+ productRankingWeeklyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore())
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
index c5e3bc7a35..71a9071861 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
@SpringBootTest
+@TestPropertySource(properties = "spring.batch.job.enabled=false")
public class CommerceBatchApplicationTest {
@Test
void contextLoads() {}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java
new file mode 100644
index 0000000000..cba14f8aa0
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java
@@ -0,0 +1,167 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.loopers.batch.job.ranking.MonthlyRankingJobConfig;
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.ranking.ProductRankingMonthly;
+import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository;
+import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+
+@SpringBootTest
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME)
+class MonthlyRankingJobE2ETest {
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ @Qualifier(MonthlyRankingJobConfig.JOB_NAME)
+ private Job job;
+
+ @Autowired
+ private ProductMetricsJpaRepository productMetricsJpaRepository;
+
+ @Autowired
+ private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("monthlyRankingJob을 실행할 때,")
+ @Nested
+ class RunJob {
+
+ @DisplayName("date 파라미터가 없으면, Job이 실패한다.")
+ @Test
+ void failsJob_whenDateParameterIsMissing() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ // act
+ var jobParameters = new JobParametersBuilder()
+ .addLong("run.id", System.nanoTime())
+ .toJobParameters();
+ var jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
+
+ // assert
+ assertThat(jobExecution.getExitStatus().getExitCode())
+ .isEqualTo(ExitStatus.FAILED.getExitCode());
+ }
+
+ @DisplayName("30일치 product_metrics를 집계하여 월간 랭킹을 저장한다.")
+ @Test
+ void aggregatesMonthlyRanking() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ // 30일 범위 내 데이터 (date=20260414 → 03/15 ~ 04/13)
+ saveMetrics(1L, LocalDate.of(2026, 3, 20), 100L, 10L, 5L);
+ saveMetrics(1L, LocalDate.of(2026, 4, 5), 200L, 20L, 10L);
+ saveMetrics(2L, LocalDate.of(2026, 4, 10), 50L, 5L, 3L);
+
+ var jobParameters = new JobParametersBuilder()
+ .addString("date", "20260414")
+ .toJobParameters();
+
+ // act
+ var jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
+
+ // assert
+ List rankings = productRankingMonthlyJpaRepository.findAll();
+
+ assertAll(
+ () -> assertThat(jobExecution.getExitStatus().getExitCode())
+ .isEqualTo(ExitStatus.COMPLETED.getExitCode()),
+ () -> assertThat(rankings).hasSize(2),
+ () -> {
+ // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5
+ ProductRankingMonthly product1 = rankings.stream()
+ .filter(r -> r.getProductId().equals(1L))
+ .findFirst().orElseThrow();
+ assertThat(product1.getScore()).isEqualTo(46.5);
+ assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14));
+ },
+ () -> {
+ // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1
+ ProductRankingMonthly product2 = rankings.stream()
+ .filter(r -> r.getProductId().equals(2L))
+ .findFirst().orElseThrow();
+ assertThat(product2.getScore()).isEqualTo(8.1);
+ }
+ );
+ }
+
+ @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).")
+ @Test
+ void isIdempotent_whenRerunWithSameParameters() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ saveMetrics(1L, LocalDate.of(2026, 4, 1), 100L, 10L, 5L);
+
+ var jobParameters1 = new JobParametersBuilder()
+ .addString("date", "20260414")
+ .addLong("run.id", 1L)
+ .toJobParameters();
+ var jobParameters2 = new JobParametersBuilder()
+ .addString("date", "20260414")
+ .addLong("run.id", 2L)
+ .toJobParameters();
+
+ // act
+ jobLauncherTestUtils.launchJob(jobParameters1);
+ jobLauncherTestUtils.launchJob(jobParameters2);
+
+ // assert
+ List rankings = productRankingMonthlyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(rankings).hasSize(1),
+ () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L)
+ );
+ }
+ }
+
+ private void saveMetrics(Long productId, LocalDate metricDate,
+ Long viewCount, Long likeCount, Long orderCount) {
+ try {
+ var constructor = ProductMetrics.class.getDeclaredConstructor();
+ constructor.setAccessible(true);
+ ProductMetrics metrics = constructor.newInstance();
+ ReflectionTestUtils.setField(metrics, "productId", productId);
+ ReflectionTestUtils.setField(metrics, "metricDate", metricDate);
+ ReflectionTestUtils.setField(metrics, "viewCount", viewCount);
+ ReflectionTestUtils.setField(metrics, "likeCount", likeCount);
+ ReflectionTestUtils.setField(metrics, "orderCount", orderCount);
+ productMetricsJpaRepository.save(metrics);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java
new file mode 100644
index 0000000000..52218922ac
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java
@@ -0,0 +1,127 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.core.StepContribution;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.ProductScoreProjection;
+import com.loopers.domain.ranking.ProductRankingMonthly;
+import com.loopers.domain.ranking.ProductRankingMonthlyRepository;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyRankingTaskletTest {
+
+ @InjectMocks
+ private MonthlyRankingTasklet tasklet;
+
+ @Mock
+ private ProductMetricsRepository productMetricsRepository;
+
+ @Mock
+ private ProductRankingMonthlyRepository productRankingMonthlyRepository;
+
+ @DisplayName("월간 랭킹 집계를 수행할 때,")
+ @Nested
+ class Execute {
+
+ @DisplayName("date 파라미터가 null이면, 예외가 발생한다.")
+ @Test
+ void throwsException_whenDateIsNull() {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", null);
+
+ // act & assert
+ assertThatThrownBy(() -> tasklet.execute(
+ mock(StepContribution.class), mock(ChunkContext.class)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("date");
+ }
+
+ @DisplayName("DB에서 조회한 Top N 스코어를 월간 랭킹으로 저장한다.")
+ @Test
+ @SuppressWarnings("unchecked")
+ void savesTopNScoresAsMonthlyRanking() throws Exception {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", "20260414");
+
+ List topScores = List.of(
+ new ProductScoreProjection(1L, 46.5),
+ new ProductScoreProjection(2L, 8.1)
+ );
+
+ given(productMetricsRepository.findTopScores(any(), any(), eq(100)))
+ .willReturn(topScores);
+
+ // act
+ tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class));
+
+ // assert
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankingMonthlyRepository).saveAll(captor.capture());
+
+ List rankings = captor.getValue();
+
+ assertAll(
+ () -> assertThat(rankings).hasSize(2),
+ () -> {
+ ProductRankingMonthly product1 = rankings.stream()
+ .filter(r -> r.getProductId().equals(1L))
+ .findFirst().orElseThrow();
+ assertThat(product1.getScore()).isEqualTo(46.5);
+ assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14));
+ },
+ () -> {
+ ProductRankingMonthly product2 = rankings.stream()
+ .filter(r -> r.getProductId().equals(2L))
+ .findFirst().orElseThrow();
+ assertThat(product2.getScore()).isEqualTo(8.1);
+ }
+ );
+ }
+
+ @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 30일 전 ~ 전날).")
+ @Test
+ void calculatesCorrectDateRange() throws Exception {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", "20260414");
+
+ given(productMetricsRepository.findTopScores(any(), any(), eq(100)))
+ .willReturn(List.of());
+
+ // act
+ tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class));
+
+ // assert
+ ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100));
+
+ assertAll(
+ () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 3, 15)),
+ () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 13))
+ );
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java
new file mode 100644
index 0000000000..1961fb7f46
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java
@@ -0,0 +1,87 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+import com.loopers.batch.job.ranking.MonthlyRankingJobConfig;
+import com.loopers.domain.ranking.ProductRankingMonthly;
+import com.loopers.domain.ranking.ProductRankingMonthlyRepository;
+import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+
+@SpringBootTest
+@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME)
+class ProductRankingMonthlyRepositoryIntegrationTest {
+
+ @Autowired
+ private ProductRankingMonthlyRepository productRankingMonthlyRepository;
+
+ @Autowired
+ private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("월간 랭킹을 저장할 때,")
+ @Nested
+ class SaveAll {
+
+ @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.")
+ @Test
+ void updatesScore_whenDuplicateKey() {
+ // arrange
+ LocalDate scoreDate = LocalDate.of(2026, 4, 14);
+ var firstRanking = ProductRankingMonthly.create(1L, scoreDate, 100.0);
+ productRankingMonthlyRepository.saveAll(List.of(firstRanking));
+
+ // act
+ var updatedRanking = ProductRankingMonthly.create(1L, scoreDate, 200.0);
+ productRankingMonthlyRepository.saveAll(List.of(updatedRanking));
+
+ // assert
+ List results = productRankingMonthlyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(results).hasSize(1),
+ () -> assertThat(results.get(0).getScore()).isEqualTo(200.0)
+ );
+ }
+
+ @DisplayName("다른 score_date이면, 별도 row로 저장된다.")
+ @Test
+ void createsSeparateRows_whenDifferentScoreDate() {
+ // arrange
+ LocalDate date1 = LocalDate.of(2026, 4, 14);
+ LocalDate date2 = LocalDate.of(2026, 4, 15);
+
+ // act
+ productRankingMonthlyRepository.saveAll(
+ List.of(ProductRankingMonthly.create(1L, date1, 100.0)));
+ productRankingMonthlyRepository.saveAll(
+ List.of(ProductRankingMonthly.create(1L, date2, 150.0)));
+
+ // assert
+ List results = productRankingMonthlyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(results).hasSize(2),
+ () -> assertThat(results).extracting(ProductRankingMonthly::getScore)
+ .containsExactlyInAnyOrder(100.0, 150.0)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java
new file mode 100644
index 0000000000..d0bbb51ce8
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java
@@ -0,0 +1,87 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+
+import com.loopers.batch.job.ranking.WeeklyRankingJobConfig;
+import com.loopers.domain.ranking.ProductRankingWeekly;
+import com.loopers.domain.ranking.ProductRankingWeeklyRepository;
+import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+
+@SpringBootTest
+@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME)
+class ProductRankingWeeklyRepositoryIntegrationTest {
+
+ @Autowired
+ private ProductRankingWeeklyRepository productRankingWeeklyRepository;
+
+ @Autowired
+ private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("주간 랭킹을 저장할 때,")
+ @Nested
+ class SaveAll {
+
+ @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.")
+ @Test
+ void updatesScore_whenDuplicateKey() {
+ // arrange
+ LocalDate scoreDate = LocalDate.of(2026, 4, 13);
+ var firstRanking = ProductRankingWeekly.create(1L, scoreDate, 100.0);
+ productRankingWeeklyRepository.saveAll(List.of(firstRanking));
+
+ // act
+ var updatedRanking = ProductRankingWeekly.create(1L, scoreDate, 200.0);
+ productRankingWeeklyRepository.saveAll(List.of(updatedRanking));
+
+ // assert
+ List results = productRankingWeeklyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(results).hasSize(1),
+ () -> assertThat(results.get(0).getScore()).isEqualTo(200.0)
+ );
+ }
+
+ @DisplayName("다른 score_date이면, 별도 row로 저장된다.")
+ @Test
+ void createsSeparateRows_whenDifferentScoreDate() {
+ // arrange
+ LocalDate date1 = LocalDate.of(2026, 4, 13);
+ LocalDate date2 = LocalDate.of(2026, 4, 14);
+
+ // act
+ productRankingWeeklyRepository.saveAll(
+ List.of(ProductRankingWeekly.create(1L, date1, 100.0)));
+ productRankingWeeklyRepository.saveAll(
+ List.of(ProductRankingWeekly.create(1L, date2, 150.0)));
+
+ // assert
+ List results = productRankingWeeklyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(results).hasSize(2),
+ () -> assertThat(results).extracting(ProductRankingWeekly::getScore)
+ .containsExactlyInAnyOrder(100.0, 150.0)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java
new file mode 100644
index 0000000000..c0500afda0
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java
@@ -0,0 +1,166 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.loopers.batch.job.ranking.WeeklyRankingJobConfig;
+import com.loopers.domain.metrics.ProductMetrics;
+import com.loopers.domain.ranking.ProductRankingWeekly;
+import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository;
+import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+
+@SpringBootTest
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME)
+class WeeklyRankingJobE2ETest {
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ @Qualifier(WeeklyRankingJobConfig.JOB_NAME)
+ private Job job;
+
+ @Autowired
+ private ProductMetricsJpaRepository productMetricsJpaRepository;
+
+ @Autowired
+ private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("weeklyRankingJob을 실행할 때,")
+ @Nested
+ class RunJob {
+
+ @DisplayName("date 파라미터가 없으면, Job이 실패한다.")
+ @Test
+ void failsJob_whenDateParameterIsMissing() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ // act
+ var jobParameters = new JobParametersBuilder()
+ .addLong("run.id", System.nanoTime())
+ .toJobParameters();
+ var jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
+
+ // assert
+ assertThat(jobExecution.getExitStatus().getExitCode())
+ .isEqualTo(ExitStatus.FAILED.getExitCode());
+ }
+
+ @DisplayName("7일치 product_metrics를 집계하여 주간 랭킹을 저장한다.")
+ @Test
+ void aggregatesWeeklyRanking() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ saveMetrics(1L, LocalDate.of(2026, 4, 7), 100L, 10L, 5L);
+ saveMetrics(1L, LocalDate.of(2026, 4, 8), 200L, 20L, 10L);
+ saveMetrics(2L, LocalDate.of(2026, 4, 9), 50L, 5L, 3L);
+
+ var jobParameters = new JobParametersBuilder()
+ .addString("date", "20260413")
+ .toJobParameters();
+
+ // act
+ var jobExecution = jobLauncherTestUtils.launchJob(jobParameters);
+
+ // assert
+ List rankings = productRankingWeeklyJpaRepository.findAll();
+
+ assertAll(
+ () -> assertThat(jobExecution.getExitStatus().getExitCode())
+ .isEqualTo(ExitStatus.COMPLETED.getExitCode()),
+ () -> assertThat(rankings).hasSize(2),
+ () -> {
+ // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5
+ ProductRankingWeekly product1 = rankings.stream()
+ .filter(r -> r.getProductId().equals(1L))
+ .findFirst().orElseThrow();
+ assertThat(product1.getScore()).isEqualTo(46.5);
+ assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13));
+ },
+ () -> {
+ // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1
+ ProductRankingWeekly product2 = rankings.stream()
+ .filter(r -> r.getProductId().equals(2L))
+ .findFirst().orElseThrow();
+ assertThat(product2.getScore()).isEqualTo(8.1);
+ }
+ );
+ }
+
+ @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).")
+ @Test
+ void isIdempotent_whenRerunWithSameParameters() throws Exception {
+ // arrange
+ jobLauncherTestUtils.setJob(job);
+
+ saveMetrics(1L, LocalDate.of(2026, 4, 10), 100L, 10L, 5L);
+
+ var jobParameters1 = new JobParametersBuilder()
+ .addString("date", "20260413")
+ .addLong("run.id", 1L)
+ .toJobParameters();
+ var jobParameters2 = new JobParametersBuilder()
+ .addString("date", "20260413")
+ .addLong("run.id", 2L)
+ .toJobParameters();
+
+ // act
+ jobLauncherTestUtils.launchJob(jobParameters1);
+ jobLauncherTestUtils.launchJob(jobParameters2);
+
+ // assert
+ List rankings = productRankingWeeklyJpaRepository.findAll();
+ assertAll(
+ () -> assertThat(rankings).hasSize(1),
+ () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L)
+ );
+ }
+ }
+
+ private void saveMetrics(Long productId, LocalDate metricDate,
+ Long viewCount, Long likeCount, Long orderCount) {
+ try {
+ var constructor = ProductMetrics.class.getDeclaredConstructor();
+ constructor.setAccessible(true);
+ ProductMetrics metrics = constructor.newInstance();
+ ReflectionTestUtils.setField(metrics, "productId", productId);
+ ReflectionTestUtils.setField(metrics, "metricDate", metricDate);
+ ReflectionTestUtils.setField(metrics, "viewCount", viewCount);
+ ReflectionTestUtils.setField(metrics, "likeCount", likeCount);
+ ReflectionTestUtils.setField(metrics, "orderCount", orderCount);
+ productMetricsJpaRepository.save(metrics);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java
new file mode 100644
index 0000000000..516cb8f6cb
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java
@@ -0,0 +1,127 @@
+package com.loopers.job.ranking;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+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.core.StepContribution;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet;
+import com.loopers.domain.metrics.ProductMetricsRepository;
+import com.loopers.domain.metrics.ProductScoreProjection;
+import com.loopers.domain.ranking.ProductRankingWeekly;
+import com.loopers.domain.ranking.ProductRankingWeeklyRepository;
+
+@ExtendWith(MockitoExtension.class)
+class WeeklyRankingTaskletTest {
+
+ @InjectMocks
+ private WeeklyRankingTasklet tasklet;
+
+ @Mock
+ private ProductMetricsRepository productMetricsRepository;
+
+ @Mock
+ private ProductRankingWeeklyRepository productRankingWeeklyRepository;
+
+ @DisplayName("주간 랭킹 집계를 수행할 때,")
+ @Nested
+ class Execute {
+
+ @DisplayName("date 파라미터가 null이면, 예외가 발생한다.")
+ @Test
+ void throwsException_whenDateIsNull() {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", null);
+
+ // act & assert
+ assertThatThrownBy(() -> tasklet.execute(
+ mock(StepContribution.class), mock(ChunkContext.class)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("date");
+ }
+
+ @DisplayName("DB에서 조회한 Top N 스코어를 주간 랭킹으로 저장한다.")
+ @Test
+ @SuppressWarnings("unchecked")
+ void savesTopNScoresAsWeeklyRanking() throws Exception {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", "20260413");
+
+ List topScores = List.of(
+ new ProductScoreProjection(1L, 46.5),
+ new ProductScoreProjection(2L, 8.1)
+ );
+
+ given(productMetricsRepository.findTopScores(any(), any(), eq(100)))
+ .willReturn(topScores);
+
+ // act
+ tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class));
+
+ // assert
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(productRankingWeeklyRepository).saveAll(captor.capture());
+
+ List rankings = captor.getValue();
+
+ assertAll(
+ () -> assertThat(rankings).hasSize(2),
+ () -> {
+ ProductRankingWeekly product1 = rankings.stream()
+ .filter(r -> r.getProductId().equals(1L))
+ .findFirst().orElseThrow();
+ assertThat(product1.getScore()).isEqualTo(46.5);
+ assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13));
+ },
+ () -> {
+ ProductRankingWeekly product2 = rankings.stream()
+ .filter(r -> r.getProductId().equals(2L))
+ .findFirst().orElseThrow();
+ assertThat(product2.getScore()).isEqualTo(8.1);
+ }
+ );
+ }
+
+ @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 7일 전 ~ 전날).")
+ @Test
+ void calculatesCorrectDateRange() throws Exception {
+ // arrange
+ ReflectionTestUtils.setField(tasklet, "date", "20260413");
+
+ given(productMetricsRepository.findTopScores(any(), any(), eq(100)))
+ .willReturn(List.of());
+
+ // act
+ tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class));
+
+ // assert
+ ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class);
+ verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100));
+
+ assertAll(
+ () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 6)),
+ () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 12))
+ );
+ }
+ }
+}
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
index fb9d2b8b84..fb566d3d96 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
@@ -1,6 +1,6 @@
package com.loopers.domain.metrics;
-import java.time.LocalDateTime;
+import java.time.LocalDate;
import java.time.ZonedDateTime;
import jakarta.persistence.Column;
@@ -8,6 +8,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
+import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@@ -19,7 +20,9 @@
@Entity
@Table(name = "product_metrics", uniqueConstraints = {
- @UniqueConstraint(columnNames = {"product_id", "metric_hour"})
+ @UniqueConstraint(columnNames = {"product_id", "metric_date"})
+}, indexes = {
+ @Index(name = "idx_metric_date", columnList = "metric_date")
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@@ -33,7 +36,7 @@ public class ProductMetrics {
private Long productId;
@Column(nullable = false)
- private LocalDateTime metricHour;
+ private LocalDate metricDate;
@Column(nullable = false)
private Long likeCount;
diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java
index c0ab338104..d922a184c2 100644
--- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java
+++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java
@@ -10,24 +10,24 @@
public interface ProductMetricsJpaRepository extends JpaRepository {
@Modifying
- @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) "
- + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), GREATEST(:delta, 0), 0, 0, NOW()) "
+ @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) "
+ + "VALUES (:productId, CURDATE(), GREATEST(:delta, 0), 0, 0, NOW()) "
+ "ON DUPLICATE KEY UPDATE "
+ "like_count = GREATEST(like_count + :delta, 0), updated_at = NOW()",
nativeQuery = true)
void upsertLikeCount(@Param("productId") Long productId, @Param("delta") Long delta);
@Modifying
- @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) "
- + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, :quantity, 0, NOW()) "
+ @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) "
+ + "VALUES (:productId, CURDATE(), 0, :quantity, 0, NOW()) "
+ "ON DUPLICATE KEY UPDATE "
+ "order_count = order_count + :quantity, updated_at = NOW()",
nativeQuery = true)
void upsertOrderCount(@Param("productId") Long productId, @Param("quantity") Long quantity);
@Modifying
- @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) "
- + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, 0, 1, NOW()) "
+ @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) "
+ + "VALUES (:productId, CURDATE(), 0, 0, 1, NOW()) "
+ "ON DUPLICATE KEY UPDATE "
+ "view_count = view_count + 1, updated_at = NOW()",
nativeQuery = true)
diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java
index c77c4f63f3..adbe8e0c30 100644
--- a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java
+++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java
@@ -3,8 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
-import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
+import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
@@ -33,7 +32,7 @@ class ProductMetricsRepositoryImplTest {
@Autowired
private DatabaseCleanUp databaseCleanUp;
- private static final LocalDateTime CURRENT_METRIC_HOUR = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS);
+ private static final LocalDate CURRENT_METRIC_DATE = LocalDate.now();
@AfterEach
void tearDown() {
@@ -74,7 +73,7 @@ void insertsNewRow_whenNotExists() {
() -> assertThat(results).hasSize(1),
() -> assertThat(results.get(0).getProductId()).isEqualTo(1L),
() -> assertThat(results.get(0).getLikeCount()).isEqualTo(1L),
- () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR)
+ () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE)
);
}
@@ -127,7 +126,7 @@ void insertsNewRow_whenNotExists() {
() -> assertThat(results).hasSize(1),
() -> assertThat(results.get(0).getProductId()).isEqualTo(1L),
() -> assertThat(results.get(0).getOrderCount()).isEqualTo(3L),
- () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR)
+ () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE)
);
}
@@ -165,7 +164,7 @@ void insertsNewRow_whenNotExists() {
() -> assertThat(results).hasSize(1),
() -> assertThat(results.get(0).getProductId()).isEqualTo(1L),
() -> assertThat(results.get(0).getViewCount()).isEqualTo(1L),
- () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR)
+ () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE)
);
}