From 6c4ca6db1f7414a33ae163518c046b70662e496e Mon Sep 17 00:00:00 2001 From: seonmin Date: Thu, 16 Apr 2026 07:31:39 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20Spring=20Batch=20Job=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeeklyRankingJob: targetDate(월요일) 기준 해당 주 7일 product_metrics 집계 - MonthlyRankingJob: targetYearMonth(yyyyMM) 기준 해당 월 전체 집계 - Chunk-Oriented 처리 (JdbcCursorItemReader → RankingProcessor → JpaItemWriter) - score = like*0.2 + view*0.1 + 0.7*log1p(sales), 상위 100개만 MV 테이블에 적재 - MvProductRankWeekly, MvProductRankMonthly 엔티티 추가 - E2E 테스트 작성 (파라미터 누락/빈 메트릭/100개 이하/100개 초과 케이스) Co-Authored-By: Claude Sonnet 4.6 --- .../monthly/MonthlyRankingJobConfig.java | 140 +++++++++++++++++ .../monthly/step/MonthlyRankingProcessor.java | 48 ++++++ .../step/TruncateMonthlyMvTasklet.java | 27 ++++ .../weekly/WeeklyRankingJobConfig.java | 141 ++++++++++++++++++ .../weekly/step/TruncateWeeklyMvTasklet.java | 27 ++++ .../weekly/step/WeeklyRankingProcessor.java | 41 +++++ .../domain/ranking/MvProductRankMonthly.java | 71 +++++++++ .../domain/ranking/MvProductRankWeekly.java | 71 +++++++++ .../domain/ranking/ProductAggregation.java | 9 ++ .../job/ranking/MonthlyRankingJobE2ETest.java | 132 ++++++++++++++++ .../job/ranking/WeeklyRankingJobE2ETest.java | 136 +++++++++++++++++ 11 files changed, 843 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..9c772496ae --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -0,0 +1,140 @@ +package com.loopers.batch.job.ranking.monthly; + +import com.loopers.batch.job.ranking.monthly.step.MonthlyRankingProcessor; +import com.loopers.batch.job.ranking.monthly.step.TruncateMonthlyMvTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.ProductAggregation; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +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.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.JpaTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +// spring.batch.job.name=monthlyRankingJob 일 때만 활성화 +// Job 흐름: truncateMonthlyMvStep → monthlyAggregateAndRankStep +@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_TRUNCATE = "truncateMonthlyMvStep"; + private static final String STEP_AGGREGATE_AND_RANK = "monthlyAggregateAndRankStep"; + private static final int CHUNK_SIZE = 10; + + // score = like * 0.2 + view * 0.1 + 0.7 * LOG(1 + sales) + // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) + private static final String MONTHLY_METRICS_SQL = """ + SELECT product_id, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount))) DESC + """; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final TruncateMonthlyMvTasklet truncateMonthlyMvTasklet; + private final MonthlyRankingProcessor monthlyRankingProcessor; + private final EntityManagerFactory entityManagerFactory; + private final DataSource dataSource; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(truncateMonthlyMvStep()) + .next(monthlyAggregateAndRankStep()) + .listener(jobListener) + .build(); + } + + // Step 1: 이전 집계 결과 전체 삭제 (재실행 시 중복 방지) + @JobScope + @Bean(STEP_TRUNCATE) + public Step truncateMonthlyMvStep() { + return new StepBuilder(STEP_TRUNCATE, jobRepository) + .tasklet(truncateMonthlyMvTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } + + // Step 2: product_metrics 집계 → 상위 100개 랭킹 산출 → mv_product_rank_monthly 저장 + // monthlyMetricsItemReader(null): @Configuration CGLIB 가 메서드 호출을 가로채 @StepScope 프록시를 반환 + @JobScope + @Bean(STEP_AGGREGATE_AND_RANK) + public Step monthlyAggregateAndRankStep() { + return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) + .chunk(CHUNK_SIZE, new JpaTransactionManager(entityManagerFactory)) + .reader(monthlyMetricsItemReader(null)) + .processor(monthlyRankingProcessor) + .writer(monthlyRankingItemWriter()) + .listener(stepMonitorListener) + .build(); + } + + // targetYearMonth job 파라미터 늦은 바인딩 (yyyyMM 형식) — 집계 범위: [해당 월 1일, 다음 달 1일) + @StepScope + @Bean + public JdbcCursorItemReader monthlyMetricsItemReader( + @Value("#{jobParameters['targetYearMonth']}") String targetYearMonth + ) { + YearMonth yearMonth = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")); + LocalDateTime startDateTime = yearMonth.atDay(1).atStartOfDay(); + LocalDateTime endDateTime = yearMonth.plusMonths(1).atDay(1).atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyMetricsItemReader") + .dataSource(dataSource) + .sql(MONTHLY_METRICS_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDateTime); + ps.setObject(2, endDateTime); + }) + .rowMapper((rs, rowNum) -> new ProductAggregation( + rs.getLong("product_id"), + rs.getLong("total_like"), + rs.getLong("total_order"), + rs.getLong("total_view"), + rs.getLong("total_sales") + )) + .build(); + } + + @Bean + public JpaItemWriter monthlyRankingItemWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java new file mode 100644 index 0000000000..eab35ee28f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.ranking.monthly.step; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.ProductAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.atomic.AtomicInteger; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Component +public class MonthlyRankingProcessor implements ItemProcessor { + + // Step 생명주기 동안 rank 순번을 유지 (@StepScope 이므로 Step 종료 시 폐기) + private final AtomicInteger rankCounter = new AtomicInteger(0); + + // baseDate 는 해당 월의 1일로 저장 (예: 202604 → 2026-04-01) + private LocalDate baseDate; + + @Value("#{jobParameters['targetYearMonth']}") + public void setTargetYearMonth(String targetYearMonth) { + this.baseDate = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")).atDay(1); + } + + @Override + public MvProductRankMonthly process(ProductAggregation item) { + int rank = rankCounter.incrementAndGet(); + if (rank > 100) { + return null; // 101위 이후는 null 반환 → Writer 에서 제외 + } + double score = item.totalView() * 0.1 + + item.totalLike() * 0.2 + + 0.7 * Math.log1p(item.totalSales()); + return MvProductRankMonthly.of( + item.productId(), rank, score, + item.totalLike(), item.totalOrder(), item.totalView(), + item.totalSales(), baseDate + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java new file mode 100644 index 0000000000..41a2945e31 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java @@ -0,0 +1,27 @@ +package com.loopers.batch.job.ranking.monthly.step; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import lombok.RequiredArgsConstructor; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class TruncateMonthlyMvTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..b0c4d54e11 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -0,0 +1,141 @@ +package com.loopers.batch.job.ranking.weekly; + +import com.loopers.batch.job.ranking.weekly.step.TruncateWeeklyMvTasklet; +import com.loopers.batch.job.ranking.weekly.step.WeeklyRankingProcessor; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductAggregation; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +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.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.orm.jpa.JpaTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.LocalDateTime; + +// spring.batch.job.name=weeklyRankingJob 일 때만 활성화 +// Job 흐름: truncateWeeklyMvStep → weeklyAggregateAndRankStep +@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_TRUNCATE = "truncateWeeklyMvStep"; + private static final String STEP_AGGREGATE_AND_RANK = "weeklyAggregateAndRankStep"; + private static final int CHUNK_SIZE = 10; + + // score = like * 0.2 + view * 0.1 + 0.7 * LOG(1 + sales) + // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) + private static final String WEEKLY_METRICS_SQL = """ + SELECT product_id, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount))) DESC + """; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final TruncateWeeklyMvTasklet truncateWeeklyMvTasklet; + private final WeeklyRankingProcessor weeklyRankingProcessor; + private final EntityManagerFactory entityManagerFactory; + private final DataSource dataSource; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(truncateWeeklyMvStep()) + .next(weeklyAggregateAndRankStep()) + .listener(jobListener) + .build(); + } + + // Step 1: 이전 집계 결과 전체 삭제 (재실행 시 중복 방지) + @JobScope + @Bean(STEP_TRUNCATE) + public Step truncateWeeklyMvStep() { + return new StepBuilder(STEP_TRUNCATE, jobRepository) + .tasklet(truncateWeeklyMvTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } + + // Step 2: product_metrics 집계 → 상위 100개 랭킹 산출 → mv_product_rank_weekly 저장 + // weeklyMetricsItemReader(null): @Configuration CGLIB 가 메서드 호출을 가로채 @StepScope 프록시를 반환 + @JobScope + @Bean(STEP_AGGREGATE_AND_RANK) + public Step weeklyAggregateAndRankStep() { + return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) + .chunk(CHUNK_SIZE, new JpaTransactionManager(entityManagerFactory)) + .reader(weeklyMetricsItemReader(null)) + .processor(weeklyRankingProcessor) + .writer(weeklyRankingItemWriter()) + .listener(stepMonitorListener) + .build(); + } + + // targetDate job 파라미터 늦은 바인딩 — targetDate 는 해당 주 월요일, 집계 범위: [월요일, 월요일+7일) + @StepScope + @Bean + public JdbcCursorItemReader weeklyMetricsItemReader( + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + if (targetDate.getDayOfWeek() != java.time.DayOfWeek.MONDAY) { + throw new IllegalArgumentException("targetDate 는 월요일이어야 합니다: " + targetDate); + } + LocalDateTime startDateTime = targetDate.atStartOfDay(); + LocalDateTime endDateTime = targetDate.plusDays(7).atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyMetricsItemReader") + .dataSource(dataSource) + .sql(WEEKLY_METRICS_SQL) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDateTime); + ps.setObject(2, endDateTime); + }) + .rowMapper((rs, rowNum) -> new ProductAggregation( + rs.getLong("product_id"), + rs.getLong("total_like"), + rs.getLong("total_order"), + rs.getLong("total_view"), + rs.getLong("total_sales") + )) + .build(); + } + + @Bean + public JpaItemWriter weeklyRankingItemWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java new file mode 100644 index 0000000000..4205b38f4b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java @@ -0,0 +1,27 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import lombok.RequiredArgsConstructor; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class TruncateWeeklyMvTasklet implements Tasklet { + + private final JdbcTemplate jdbcTemplate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java new file mode 100644 index 0000000000..7c483f0795 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java @@ -0,0 +1,41 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.ProductAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicInteger; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@Component +public class WeeklyRankingProcessor implements ItemProcessor { + + // Step 생명주기 동안 rank 순번을 유지 (@StepScope 이므로 Step 종료 시 폐기) + private final AtomicInteger rankCounter = new AtomicInteger(0); + + @Value("#{jobParameters['targetDate']}") + private LocalDate targetDate; + + @Override + public MvProductRankWeekly process(ProductAggregation item) { + int rank = rankCounter.incrementAndGet(); + if (rank > 100) { + return null; // 101위 이후는 null 반환 → Writer 에서 제외 + } + double score = item.totalView() * 0.1 + + item.totalLike() * 0.2 + + 0.7 * Math.log1p(item.totalSales()); + return MvProductRankWeekly.of( + item.productId(), rank, score, + item.totalLike(), item.totalOrder(), item.totalView(), + item.totalSales(), targetDate + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..d96b6b4f64 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_monthly") +public class MvProductRankMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankMonthly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankMonthly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankMonthly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..b347281f21 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_weekly") +public class MvProductRankWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankWeekly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankWeekly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankWeekly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java new file mode 100644 index 0000000000..01818f0f18 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking; + +public record ProductAggregation( + Long productId, + Long totalLike, + Long totalOrder, + Long totalView, + Long totalSales +) {} 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..0e134cd255 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,132 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@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 JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @DisplayName("targetYearMonth 파라미터 없으면 Job 이 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // arrange & act + var execution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 메트릭이 없으면 MV 테이블은 비어 있다.") + @Test + void emptyMetrics_noRankingStored() throws Exception { + // arrange + var params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isZero() + ); + } + + @DisplayName("상품이 100개 이하이면 모두 랭킹에 기록되고, rank 1 이 가장 높은 score 를 가진다.") + @Test + void fewProducts_allRankedInOrder() throws Exception { + // arrange + insertMetrics(1L, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, 1_000); + insertMetrics(2L, LocalDateTime.of(2026, 4, 16, 0, 0), 5, 5, 5, 50_000); + + var params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + Long rank1ProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_monthly WHERE rank = 1", Long.class); + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(2), + () -> assertThat(rank1ProductId).isEqualTo(2L) + ); + } + + @DisplayName("상품이 100개 초과이면 상위 100개만 기록된다.") + @Test + void manyProducts_top100Only() throws Exception { + // arrange + for (long i = 1; i <= 110; i++) { + insertMetrics(i, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, i * 1_000); + } + + var params = new JobParametersBuilder() + .addString("targetYearMonth", "202604") + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(100) + ); + } + + private void insertMetrics(long productId, LocalDateTime metricHour, + long likeCount, long orderCount, long viewCount, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, sales_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productId, metricHour, likeCount, orderCount, viewCount, salesAmount + ); + } + + private long countMv() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM mv_product_rank_monthly", Long.class); + return count != null ? count : 0L; + } +} 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..025f989f80 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,136 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@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 JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(job); + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @DisplayName("targetDate 파라미터 없으면 Job 이 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // arrange & act + var execution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 메트릭이 없으면 MV 테이블은 비어 있다.") + @Test + void emptyMetrics_noRankingStored() throws Exception { + // arrange + var params = new JobParametersBuilder() + .addLocalDate("targetDate", LocalDate.of(2026, 4, 13)) + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isZero() + ); + } + + @DisplayName("상품이 100개 이하이면 모두 랭킹에 기록되고, rank 1 이 가장 높은 score 를 가진다.") + @Test + void fewProducts_allRankedInOrder() throws Exception { + // arrange + LocalDate targetDate = LocalDate.of(2026, 4, 13); + insertMetrics(1L, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, 1_000); + insertMetrics(2L, LocalDateTime.of(2026, 4, 14, 0, 0), 5, 5, 5, 50_000); + + var params = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + Long rank1ProductId = jdbcTemplate.queryForObject( + "SELECT product_id FROM mv_product_rank_weekly WHERE rank = 1", Long.class); + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(2), + () -> assertThat(rank1ProductId).isEqualTo(2L) + ); + } + + @DisplayName("상품이 100개 초과이면 상위 100개만 기록된다.") + @Test + void manyProducts_top100Only() throws Exception { + // arrange + LocalDate targetDate = LocalDate.of(2026, 4, 13); + for (long i = 1; i <= 110; i++) { + insertMetrics(i, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, i * 1_000); + } + + var params = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + + // act + var execution = jobLauncherTestUtils.launchJob(params); + + // assert + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countMv()).isEqualTo(100) + ); + } + + private void insertMetrics(long productId, LocalDateTime metricHour, + long likeCount, long orderCount, long viewCount, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, sales_amount, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())", + productId, metricHour, likeCount, orderCount, viewCount, salesAmount + ); + } + + private long countMv() { + Long count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM mv_product_rank_weekly", Long.class); + return count != null ? count : 0L; + } +} From 1d4fe407fe0b97954e7278d4fdc89aac6f6c2cf9 Mon Sep 17 00:00:00 2001 From: Seonmin Date: Thu, 16 Apr 2026 14:10:53 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Ranking=20API=EC=97=90=20period=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BC=EA=B0=84/=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/rankings?period=DAILY|WEEKLY|MONTHLY 지원 (기본값: DAILY) - DAILY: 기존 Redis ZSET 기반 일간 랭킹 유지 - WEEKLY: mv_product_rank_weekly 테이블 기반 (date 속한 주의 월요일 기준) - MONTHLY: mv_product_rank_monthly 테이블 기반 (date 속한 월의 1일 기준) - commerce-batch SQL에 LIMIT 100 추가 및 TruncateTasklet을 기간별 삭제로 변경 (멱등성) Co-Authored-By: Claude Sonnet 4.6 --- .../application/ranking/RankingFacade.java | 32 ++- .../ranking/MonthlyRankingRepository.java | 11 + .../domain/ranking/MvProductRankMonthly.java | 71 +++++++ .../domain/ranking/MvProductRankWeekly.java | 71 +++++++ .../loopers/domain/ranking/RankingPeriod.java | 7 + .../ranking/WeeklyRankingRepository.java | 11 + .../ranking/JpaMonthlyRankingRepository.java | 28 +++ .../ranking/JpaWeeklyRankingRepository.java | 28 +++ .../ranking/MonthlyRankingJpaRepository.java | 18 ++ .../ranking/WeeklyRankingJpaRepository.java | 18 ++ .../api/ranking/RankingController.java | 4 +- .../ranking/RankingFacadeTest.java | 107 +++++++++- .../JpaMonthlyRankingRepositoryTest.java | 120 +++++++++++ .../JpaWeeklyRankingRepositoryTest.java | 120 +++++++++++ .../api/ranking/RankingApiE2ETest.java | 201 ++++++++++++++++++ .../monthly/MonthlyRankingJobConfig.java | 1 + .../monthly/step/MonthlyRankingProcessor.java | 3 - .../step/TruncateMonthlyMvTasklet.java | 14 +- .../weekly/WeeklyRankingJobConfig.java | 1 + .../weekly/step/TruncateWeeklyMvTasklet.java | 8 +- .../weekly/step/WeeklyRankingProcessor.java | 9 +- .../job/ranking/MonthlyRankingJobE2ETest.java | 16 +- .../job/ranking/WeeklyRankingJobE2ETest.java | 17 +- http/commerce-api/ranking.http | 21 ++ 24 files changed, 905 insertions(+), 32 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java create mode 100644 http/commerce-api/ranking.http diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index a050b22380..164cabbad4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -6,13 +6,18 @@ import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.WeeklyRankingRepository; import com.loopers.support.page.PageResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -22,20 +27,22 @@ public class RankingFacade { private final RankingRepository rankingRepository; + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; private final ProductRepository productRepository; private final BrandRepository brandRepository; private final ProductAssembler productAssembler; @Transactional(readOnly = true) - public PageResponse getPage(LocalDate date, int page, int size) { + public PageResponse getPage(LocalDate date, RankingPeriod period, int page, int size) { long offset = (long) (page - 1) * size; - List productIds = rankingRepository.findProductIdsByRank(date, offset, (long) size); + List productIds = resolveProductIds(date, period, offset, (long) size); if (productIds.isEmpty()) { return new PageResponse<>(List.of(), page, size, 0); } - long count = rankingRepository.countByDate(date); + long count = resolveCount(date, period); int totalPages = (int) Math.ceil((double) count / size); List products = productRepository.findAllByIdIn(productIds); @@ -55,4 +62,23 @@ public PageResponse getPage(LocalDate date, int page, int size) { return new PageResponse<>(rankingInfos, page, size, totalPages); } + + private List resolveProductIds(LocalDate date, RankingPeriod period, long offset, long limit) { + return switch (period) { + case DAILY -> rankingRepository.findProductIdsByRank(date, offset, limit); + case WEEKLY -> weeklyRankingRepository.findProductIdsByBaseDate( + date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)), offset, limit); + case MONTHLY -> monthlyRankingRepository.findProductIdsByBaseDate( + date.withDayOfMonth(1), offset, limit); + }; + } + + private long resolveCount(LocalDate date, RankingPeriod period) { + return switch (period) { + case DAILY -> rankingRepository.countByDate(date); + case WEEKLY -> weeklyRankingRepository.countByBaseDate( + date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))); + case MONTHLY -> monthlyRankingRepository.countByBaseDate(date.withDayOfMonth(1)); + }; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java new file mode 100644 index 0000000000..a1c22533d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyRankingRepository { + + List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..5cdf237dd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_monthly") +public class MvProductRankMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankMonthly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankMonthly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankMonthly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..d31f2d23d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -0,0 +1,71 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "mv_product_rank_weekly") +public class MvProductRankWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "total_like", nullable = false) + private long totalLike; + + @Column(name = "total_order", nullable = false) + private long totalOrder; + + @Column(name = "total_view", nullable = false) + private long totalView; + + @Column(name = "total_sales", nullable = false) + private long totalSales; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private MvProductRankWeekly(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + this.productId = productId; + this.rank = rank; + this.score = score; + this.totalLike = totalLike; + this.totalOrder = totalOrder; + this.totalView = totalView; + this.totalSales = totalSales; + this.baseDate = baseDate; + } + + public static MvProductRankWeekly of(Long productId, int rank, double score, + long totalLike, long totalOrder, long totalView, + long totalSales, LocalDate baseDate) { + return new MvProductRankWeekly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java new file mode 100644 index 0000000000..f23babc174 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankingPeriod { + DAILY, + WEEKLY, + MONTHLY +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java new file mode 100644 index 0000000000..b8aecf08d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingRepository { + + List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java new file mode 100644 index 0000000000..9042f8fdd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class JpaMonthlyRankingRepository implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository jpaRepository; + + @Override + public List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit) { + int pageNumber = (int) (offset / limit); + PageRequest pageRequest = PageRequest.of(pageNumber, (int) limit); + return jpaRepository.findProductIdsByBaseDate(baseDate, pageRequest); + } + + @Override + public long countByBaseDate(LocalDate baseDate) { + return jpaRepository.countByBaseDate(baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java new file mode 100644 index 0000000000..fd058bf2d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class JpaWeeklyRankingRepository implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository jpaRepository; + + @Override + public List findProductIdsByBaseDate(LocalDate baseDate, long offset, long limit) { + int pageNumber = (int) (offset / limit); + PageRequest pageRequest = PageRequest.of(pageNumber, (int) limit); + return jpaRepository.findProductIdsByBaseDate(baseDate, pageRequest); + } + + @Override + public long countByBaseDate(LocalDate baseDate) { + return jpaRepository.countByBaseDate(baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..671619b756 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +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.LocalDate; +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + @Query("SELECT m.productId FROM MvProductRankMonthly m WHERE m.baseDate = :baseDate ORDER BY m.rank ASC") + List findProductIdsByBaseDate(@Param("baseDate") LocalDate baseDate, Pageable pageable); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..2c8db66e9c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +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.LocalDate; +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + @Query("SELECT w.productId FROM MvProductRankWeekly w WHERE w.baseDate = :baseDate ORDER BY w.rank ASC") + List findProductIdsByBaseDate(@Param("baseDate") LocalDate baseDate, Pageable pageable); + + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java index 94fe4f276b..95bc219fe4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.page.PageResponse; import lombok.RequiredArgsConstructor; @@ -22,11 +23,12 @@ public class RankingController { @GetMapping("/api/v1/rankings") public ApiResponse> getRankings( @RequestParam(required = false) @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date, + @RequestParam(defaultValue = "DAILY") RankingPeriod period, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size ) { LocalDate targetDate = date != null ? date : LocalDate.now(ZoneOffset.UTC); - PageResponse infos = rankingFacade.getPage(targetDate, page, size); + PageResponse infos = rankingFacade.getPage(targetDate, period, page, size); return ApiResponse.success(infos.map(RankingDto.Response::from)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index 6c9c12d6d0..b5f92e51ae 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -7,7 +7,10 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingPeriod; import com.loopers.domain.ranking.RankingRepository; +import com.loopers.domain.ranking.WeeklyRankingRepository; import com.loopers.support.page.PageResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -23,10 +26,12 @@ class RankingFacadeTest { RankingRepository rankingRepository = mock(RankingRepository.class); + WeeklyRankingRepository weeklyRankingRepository = mock(WeeklyRankingRepository.class); + MonthlyRankingRepository monthlyRankingRepository = mock(MonthlyRankingRepository.class); ProductRepository productRepository = mock(ProductRepository.class); BrandRepository brandRepository = mock(BrandRepository.class); ProductAssembler productAssembler = new ProductAssembler(); - RankingFacade rankingFacade = new RankingFacade(rankingRepository, productRepository, brandRepository, productAssembler); + RankingFacade rankingFacade = new RankingFacade(rankingRepository, weeklyRankingRepository, monthlyRankingRepository, productRepository, brandRepository, productAssembler); @DisplayName("getPage() 를 호출할 때, ") @Nested @@ -56,7 +61,7 @@ void returnsRankingInfosInRankingOrder() { when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.content()).hasSize(2); @@ -76,7 +81,7 @@ void returnsEmptyPage_whenZSetIsEmpty() { when(rankingRepository.findProductIdsByRank(date, 0L, 20L)).thenReturn(List.of()); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.content()).isEmpty(); @@ -104,12 +109,106 @@ void calculatesTotalPagesCorrectly() { when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); // act - PageResponse result = rankingFacade.getPage(date, page, size); + PageResponse result = rankingFacade.getPage(date, RankingPeriod.DAILY, page, size); // assert assertThat(result.totalPages()).isEqualTo(2); } + @DisplayName("period=WEEKLY 이면 date 가 속한 주의 월요일 기준으로 weeklyRankingRepository 를 호출한다.") + @Test + void getPage_weekly_returnsInfos_withMondayAsBaseDate() { + // arrange + LocalDate wednesday = LocalDate.of(2026, 4, 15); // 수요일 + LocalDate monday = LocalDate.of(2026, 4, 13); // 해당 주 월요일 + int page = 1, size = 2; + + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product1 = mockProduct(10L, "상품 A", null, brandId, 10, 1000, 5L); + Product product2 = mockProduct(20L, "상품 B", null, brandId, 5, 2000, 3L); + + when(weeklyRankingRepository.findProductIdsByBaseDate(monday, 0L, 2L)).thenReturn(List.of(10L, 20L)); + when(weeklyRankingRepository.countByBaseDate(monday)).thenReturn(2L); + when(productRepository.findAllByIdIn(List.of(10L, 20L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = rankingFacade.getPage(wednesday, RankingPeriod.WEEKLY, page, size); + + // assert + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).rank()).isEqualTo(1L); + assertThat(result.content().get(0).productId()).isEqualTo(10L); + } + + @DisplayName("period=WEEKLY 이고 데이터가 없으면 빈 페이지를 반환한다.") + @Test + void getPage_weekly_returnsEmpty_whenNoData() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 13); + LocalDate monday = LocalDate.of(2026, 4, 13); + + when(weeklyRankingRepository.findProductIdsByBaseDate(monday, 0L, 20L)).thenReturn(List.of()); + + // act + PageResponse result = rankingFacade.getPage(date, RankingPeriod.WEEKLY, 1, 20); + + // assert + assertThat(result.content()).isEmpty(); + assertThat(result.totalPages()).isEqualTo(0); + } + + @DisplayName("period=MONTHLY 이면 date 가 속한 월의 1일 기준으로 monthlyRankingRepository 를 호출한다.") + @Test + void getPage_monthly_returnsInfos_withFirstDayAsBaseDate() { + // arrange + LocalDate midMonth = LocalDate.of(2026, 4, 15); // 15일 + LocalDate firstDay = LocalDate.of(2026, 4, 1); // 해당 월 1일 + int page = 1, size = 2; + + Long brandId = 1L; + Brand brand = mock(Brand.class); + when(brand.getId()).thenReturn(brandId); + when(brand.name()).thenReturn("나이키"); + + Product product1 = mockProduct(10L, "상품 A", null, brandId, 10, 1000, 5L); + Product product2 = mockProduct(20L, "상품 B", null, brandId, 5, 2000, 3L); + + when(monthlyRankingRepository.findProductIdsByBaseDate(firstDay, 0L, 2L)).thenReturn(List.of(10L, 20L)); + when(monthlyRankingRepository.countByBaseDate(firstDay)).thenReturn(2L); + when(productRepository.findAllByIdIn(List.of(10L, 20L))).thenReturn(List.of(product1, product2)); + when(brandRepository.findAllByIdIn(List.of(brandId))).thenReturn(List.of(brand)); + + // act + PageResponse result = rankingFacade.getPage(midMonth, RankingPeriod.MONTHLY, page, size); + + // assert + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).rank()).isEqualTo(1L); + assertThat(result.content().get(0).productId()).isEqualTo(10L); + } + + @DisplayName("period=MONTHLY 이고 데이터가 없으면 빈 페이지를 반환한다.") + @Test + void getPage_monthly_returnsEmpty_whenNoData() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 15); + LocalDate firstDay = LocalDate.of(2026, 4, 1); + + when(monthlyRankingRepository.findProductIdsByBaseDate(firstDay, 0L, 20L)).thenReturn(List.of()); + + // act + PageResponse result = rankingFacade.getPage(date, RankingPeriod.MONTHLY, 1, 20); + + // assert + assertThat(result.content()).isEmpty(); + assertThat(result.totalPages()).isEqualTo(0); + } + private Product mockProduct(Long id, String name, String description, Long brandId, int stockValue, int priceValue, Long likeCount) { Product product = mock(Product.class); diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java new file mode 100644 index 0000000000..26f80ece42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaMonthlyRankingRepositoryTest.java @@ -0,0 +1,120 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +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.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(MySqlTestContainersConfig.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class JpaMonthlyRankingRepositoryTest { + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("findProductIdsByBaseDate() 를 호출할 때,") + @Nested + class FindProductIdsByBaseDate { + + @DisplayName("rank 오름차순으로 productId 목록을 반환한다.") + @Test + void returnsInRankOrder() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(100L, 1, 300.0, 10, 5, 100, 5000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(200L, 2, 200.0, 8, 3, 80, 3000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(300L, 3, 100.0, 5, 1, 50, 1000, baseDate)); + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 3); + + // assert + assertThat(result).containsExactly(100L, 200L, 300L); + } + + @DisplayName("offset 과 limit 에 따라 해당 페이지만 반환한다.") + @Test + void withOffsetAndLimit() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + for (int rank = 1; rank <= 5; rank++) { + monthlyRankingJpaRepository.save(MvProductRankMonthly.of((long) rank * 10, rank, 100.0 - rank, 1, 1, 1, 1000, baseDate)); + } + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 2, 2); + + // assert + assertThat(result).containsExactly(30L, 40L); + } + + @DisplayName("해당 baseDate 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + + // act + List result = monthlyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("countByBaseDate() 를 호출할 때,") + @Nested + class CountByBaseDate { + + @DisplayName("해당 baseDate 의 전체 row 수를 반환한다.") + @Test + void returnsTotalCount() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(1L, 1, 100.0, 1, 1, 1, 1000, baseDate)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(2L, 2, 90.0, 1, 1, 1, 900, baseDate)); + + // act + long count = monthlyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isEqualTo(2); + } + + @DisplayName("해당 baseDate 데이터가 없으면 0을 반환한다.") + @Test + void returnsZero_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 1); + + // act + long count = monthlyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java new file mode 100644 index 0000000000..573888f847 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/JpaWeeklyRankingRepositoryTest.java @@ -0,0 +1,120 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +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.context.annotation.Import; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(MySqlTestContainersConfig.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +class JpaWeeklyRankingRepositoryTest { + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("findProductIdsByBaseDate() 를 호출할 때,") + @Nested + class FindProductIdsByBaseDate { + + @DisplayName("rank 오름차순으로 productId 목록을 반환한다.") + @Test + void returnsInRankOrder() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(100L, 1, 300.0, 10, 5, 100, 5000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(200L, 2, 200.0, 8, 3, 80, 3000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(300L, 3, 100.0, 5, 1, 50, 1000, baseDate)); + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 3); + + // assert + assertThat(result).containsExactly(100L, 200L, 300L); + } + + @DisplayName("offset 과 limit 에 따라 해당 페이지만 반환한다.") + @Test + void withOffsetAndLimit() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + for (int rank = 1; rank <= 5; rank++) { + weeklyRankingJpaRepository.save(MvProductRankWeekly.of((long) rank * 10, rank, 100.0 - rank, 1, 1, 1, 1000, baseDate)); + } + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 2, 2); + + // assert + assertThat(result).containsExactly(30L, 40L); + } + + @DisplayName("해당 baseDate 데이터가 없으면 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + + // act + List result = weeklyRankingRepository.findProductIdsByBaseDate(baseDate, 0, 10); + + // assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("countByBaseDate() 를 호출할 때,") + @Nested + class CountByBaseDate { + + @DisplayName("해당 baseDate 의 전체 row 수를 반환한다.") + @Test + void returnsTotalCount() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(1L, 1, 100.0, 1, 1, 1, 1000, baseDate)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(2L, 2, 90.0, 1, 1, 1, 900, baseDate)); + + // act + long count = weeklyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isEqualTo(2); + } + + @DisplayName("해당 baseDate 데이터가 없으면 0을 반환한다.") + @Test + void returnsZero_whenNoData() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 13); + + // act + long count = weeklyRankingRepository.countByBaseDate(baseDate); + + // assert + assertThat(count).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java index 6f3041680b..ce76fa4f62 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -4,8 +4,12 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.vo.Price; import com.loopers.domain.product.vo.Stock; +import com.loopers.domain.ranking.MvProductRankMonthly; +import com.loopers.domain.ranking.MvProductRankWeekly; import com.loopers.infrastructure.brand.BrandJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.ranking.MonthlyRankingJpaRepository; +import com.loopers.infrastructure.ranking.WeeklyRankingJpaRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.page.PageResponse; import com.loopers.testcontainers.RedisTestContainersConfig; @@ -47,6 +51,12 @@ class RankingApiE2ETest { @Autowired private ProductJpaRepository productJpaRepository; + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -153,5 +163,196 @@ void usesTodayDate_whenDateParamOmitted() { () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) ); } + + @DisplayName("period 파라미터가 잘못된 값이면 400을 반환한다.") + @Test + void period_invalidValue_returns400() { + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?period=INVALID", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/rankings?period=WEEKLY") + @Nested + class GetWeeklyRankings { + + @DisplayName("mv_product_rank_weekly 에 데이터가 있으면 rank 순서대로 상품 정보를 반환한다.") + @Test + void returnsRankingsInOrder_whenDataExists() { + // arrange + LocalDate monday = LocalDate.of(2026, 4, 13); + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product1 = productJpaRepository.save(Product.of("주간 상품 A", null, Stock.from(10), Price.from(1000), brand.getId())); + Product product2 = productJpaRepository.save(Product.of("주간 상품 B", null, Stock.from(5), Price.from(2000), brand.getId())); + + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product2.getId(), 1, 200.0, 5, 3, 50, 2000, monday)); + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product1.getId(), 2, 100.0, 3, 1, 30, 1000, monday)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + monday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(2), + () -> assertThat(data.content().get(0).rank()).isEqualTo(1L), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product2.getId()), + () -> assertThat(data.content().get(1).rank()).isEqualTo(2L), + () -> assertThat(data.content().get(1).productId()).isEqualTo(product1.getId()) + ); + } + + @DisplayName("date 가 수요일이면 해당 주 월요일 기준 데이터를 반환한다.") + @Test + void usesMondayAsBaseDate_whenDateIsWednesday() { + // arrange + LocalDate wednesday = LocalDate.of(2026, 4, 15); + LocalDate monday = LocalDate.of(2026, 4, 13); + Brand brand = brandJpaRepository.save(Brand.of("나이키", null)); + Product product = productJpaRepository.save(Product.of("주간 상품", null, Stock.from(10), Price.from(1000), brand.getId())); + + weeklyRankingJpaRepository.save(MvProductRankWeekly.of(product.getId(), 1, 100.0, 1, 1, 10, 1000, monday)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + wednesday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(1), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) + ); + } + + @DisplayName("데이터가 없으면 빈 content 와 totalPages=0 을 반환한다.") + @Test + void returnsEmpty_whenNoWeeklyData() { + // arrange + LocalDate monday = LocalDate.of(2026, 4, 13); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + monday.format(DATE_FORMATTER) + "&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).isEmpty(), + () -> assertThat(data.totalPages()).isEqualTo(0) + ); + } + } + + @DisplayName("GET /api/v1/rankings?period=MONTHLY") + @Nested + class GetMonthlyRankings { + + @DisplayName("mv_product_rank_monthly 에 데이터가 있으면 rank 순서대로 상품 정보를 반환한다.") + @Test + void returnsRankingsInOrder_whenDataExists() { + // arrange + LocalDate firstDay = LocalDate.of(2026, 4, 1); + Brand brand = brandJpaRepository.save(Brand.of("아디다스", null)); + Product product1 = productJpaRepository.save(Product.of("월간 상품 A", null, Stock.from(10), Price.from(1000), brand.getId())); + Product product2 = productJpaRepository.save(Product.of("월간 상품 B", null, Stock.from(5), Price.from(2000), brand.getId())); + + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product2.getId(), 1, 200.0, 5, 3, 50, 2000, firstDay)); + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product1.getId(), 2, 100.0, 3, 1, 30, 1000, firstDay)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + firstDay.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(2), + () -> assertThat(data.content().get(0).rank()).isEqualTo(1L), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product2.getId()), + () -> assertThat(data.content().get(1).rank()).isEqualTo(2L), + () -> assertThat(data.content().get(1).productId()).isEqualTo(product1.getId()) + ); + } + + @DisplayName("date 가 월 중간이면 해당 월 1일 기준 데이터를 반환한다.") + @Test + void usesFirstDayAsBaseDate_whenDateIsMiddleOfMonth() { + // arrange + LocalDate midMonth = LocalDate.of(2026, 4, 15); + LocalDate firstDay = LocalDate.of(2026, 4, 1); + Brand brand = brandJpaRepository.save(Brand.of("아디다스", null)); + Product product = productJpaRepository.save(Product.of("월간 상품", null, Stock.from(10), Price.from(1000), brand.getId())); + + monthlyRankingJpaRepository.save(MvProductRankMonthly.of(product.getId(), 1, 100.0, 1, 1, 10, 1000, firstDay)); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + midMonth.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).hasSize(1), + () -> assertThat(data.content().get(0).productId()).isEqualTo(product.getId()) + ); + } + + @DisplayName("데이터가 없으면 빈 content 와 totalPages=0 을 반환한다.") + @Test + void returnsEmpty_whenNoMonthlyData() { + // arrange + LocalDate firstDay = LocalDate.of(2026, 4, 1); + + // act + ResponseEntity>> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + firstDay.format(DATE_FORMATTER) + "&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + PageResponse data = response.getBody().data(); + assertAll( + () -> assertThat(data.content()).isEmpty(), + () -> assertThat(data.totalPages()).isEqualTo(0) + ); + } } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java index 9c772496ae..d5917f9ae5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -59,6 +59,7 @@ public class MonthlyRankingJobConfig { GROUP BY product_id ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + 0.7 * LOG(1 + SUM(sales_amount))) DESC + LIMIT 100 """; private final JobRepository jobRepository; diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java index eab35ee28f..de52c1203c 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java @@ -33,9 +33,6 @@ public void setTargetYearMonth(String targetYearMonth) { @Override public MvProductRankMonthly process(ProductAggregation item) { int rank = rankCounter.incrementAndGet(); - if (rank > 100) { - return null; // 101위 이후는 null 반환 → Writer 에서 제외 - } double score = item.totalView() * 0.1 + item.totalLike() * 0.2 + 0.7 * Math.log1p(item.totalSales()); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java index 41a2945e31..b43746f290 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/TruncateMonthlyMvTasklet.java @@ -7,10 +7,15 @@ 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.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) @RequiredArgsConstructor @@ -19,9 +24,16 @@ public class TruncateMonthlyMvTasklet implements Tasklet { private final JdbcTemplate jdbcTemplate; + private LocalDate baseDate; + + @Value("#{jobParameters['targetYearMonth']}") + public void setTargetYearMonth(String targetYearMonth) { + this.baseDate = YearMonth.parse(targetYearMonth, DateTimeFormatter.ofPattern("yyyyMM")).atDay(1); + } + @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_monthly WHERE base_date = ?", baseDate); return RepeatStatus.FINISHED; } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java index b0c4d54e11..85b863f280 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -58,6 +58,7 @@ public class WeeklyRankingJobConfig { GROUP BY product_id ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + 0.7 * LOG(1 + SUM(sales_amount))) DESC + LIMIT 100 """; private final JobRepository jobRepository; diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java index 4205b38f4b..e36dc2265a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/TruncateWeeklyMvTasklet.java @@ -7,10 +7,13 @@ 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.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; +import java.time.LocalDate; + @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) @RequiredArgsConstructor @@ -19,9 +22,12 @@ public class TruncateWeeklyMvTasklet implements Tasklet { private final JdbcTemplate jdbcTemplate; + @Value("#{jobParameters['targetDate']}") + private LocalDate baseDate; + @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.update("DELETE FROM mv_product_rank_weekly WHERE base_date = ?", baseDate); return RepeatStatus.FINISHED; } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java index 7c483f0795..9912126920 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java @@ -20,15 +20,16 @@ public class WeeklyRankingProcessor implements ItemProcessor 100) { - return null; // 101위 이후는 null 반환 → Writer 에서 제외 - } double score = item.totalView() * 0.1 + item.totalLike() * 0.2 + 0.7 * Math.log1p(item.totalSales()); 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 index 0e134cd255..6b38e789d0 100644 --- 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 @@ -6,6 +6,8 @@ import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.batch.test.context.SpringBatchTest; @@ -46,7 +48,7 @@ void setUp() { @Test void failsWithoutTargetDate() throws Exception { // arrange & act - var execution = jobLauncherTestUtils.launchJob(); + JobExecution execution = jobLauncherTestUtils.launchJob(); // assert assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); @@ -56,12 +58,12 @@ void failsWithoutTargetDate() throws Exception { @Test void emptyMetrics_noRankingStored() throws Exception { // arrange - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addString("targetYearMonth", "202604") .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert assertAll( @@ -77,12 +79,12 @@ void fewProducts_allRankedInOrder() throws Exception { insertMetrics(1L, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, 1_000); insertMetrics(2L, LocalDateTime.of(2026, 4, 16, 0, 0), 5, 5, 5, 50_000); - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addString("targetYearMonth", "202604") .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert Long rank1ProductId = jdbcTemplate.queryForObject( @@ -102,12 +104,12 @@ void manyProducts_top100Only() throws Exception { insertMetrics(i, LocalDateTime.of(2026, 4, 16, 0, 0), 1, 1, 1, i * 1_000); } - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addString("targetYearMonth", "202604") .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert assertAll( 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 index 025f989f80..b8298d554b 100644 --- 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 @@ -1,12 +1,13 @@ package com.loopers.job.ranking; import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; -import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.batch.test.context.SpringBatchTest; @@ -48,7 +49,7 @@ void setUp() { @Test void failsWithoutTargetDate() throws Exception { // arrange & act - var execution = jobLauncherTestUtils.launchJob(); + JobExecution execution = jobLauncherTestUtils.launchJob(); // assert assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); @@ -58,12 +59,12 @@ void failsWithoutTargetDate() throws Exception { @Test void emptyMetrics_noRankingStored() throws Exception { // arrange - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addLocalDate("targetDate", LocalDate.of(2026, 4, 13)) .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert assertAll( @@ -80,12 +81,12 @@ void fewProducts_allRankedInOrder() throws Exception { insertMetrics(1L, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, 1_000); insertMetrics(2L, LocalDateTime.of(2026, 4, 14, 0, 0), 5, 5, 5, 50_000); - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addLocalDate("targetDate", targetDate) .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert Long rank1ProductId = jdbcTemplate.queryForObject( @@ -106,12 +107,12 @@ void manyProducts_top100Only() throws Exception { insertMetrics(i, LocalDateTime.of(2026, 4, 14, 0, 0), 1, 1, 1, i * 1_000); } - var params = new JobParametersBuilder() + JobParameters params = new JobParametersBuilder() .addLocalDate("targetDate", targetDate) .toJobParameters(); // act - var execution = jobLauncherTestUtils.launchJob(params); + JobExecution execution = jobLauncherTestUtils.launchJob(params); // assert assertAll( diff --git a/http/commerce-api/ranking.http b/http/commerce-api/ranking.http new file mode 100644 index 0000000000..e38c7ee8a6 --- /dev/null +++ b/http/commerce-api/ranking.http @@ -0,0 +1,21 @@ +### 일간 랭킹 조회 (오늘 날짜) +GET http://localhost:8080/api/v1/rankings +Accept: application/json + +### + +### 일간 랭킹 조회 (특정 날짜) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=DAILY&page=1&size=20 +Accept: application/json + +### + +### 주간 랭킹 조회 (date 가 속한 주의 월요일 기준 집계) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=WEEKLY&page=1&size=20 +Accept: application/json + +### + +### 월간 랭킹 조회 (date 가 속한 월의 1일 기준 집계) +GET http://localhost:8080/api/v1/rankings?date=20260416&period=MONTHLY&page=1&size=20 +Accept: application/json From bc5b863323bd2eaaf22572f79c26211a2a1b421b Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 17 Apr 2026 15:19:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20ItemWriter=EB=A5=BC=20JpaItemWriter=EC=97=90?= =?UTF-8?q?=EC=84=9C=20JdbcBatchItemWriter=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JpaItemWriter → JdbcBatchItemWriter: TRUNCATE → INSERT 패턴에서 merge()의 불필요한 SELECT 제거 - JpaTransactionManager → DataSourceTransactionManager로 단순화 - MvProductRankWeekly, MvProductRankMonthly를 JPA 엔티티에서 순수 POJO로 변환 (jakarta.persistence 의존성 제거) - ProductAggregation에 score 필드 추가, SQL에서 score 계산 후 Processor 재계산 로직 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../monthly/MonthlyRankingJobConfig.java | 28 +++++++---- .../monthly/step/MonthlyRankingProcessor.java | 5 +- .../weekly/WeeklyRankingJobConfig.java | 28 +++++++---- .../weekly/step/WeeklyRankingProcessor.java | 5 +- .../domain/ranking/MvProductRankMonthly.java | 50 +++---------------- .../domain/ranking/MvProductRankWeekly.java | 50 +++---------------- .../domain/ranking/ProductAggregation.java | 1 + 7 files changed, 53 insertions(+), 114 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java index d5917f9ae5..49c7a7320c 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -6,7 +6,6 @@ import com.loopers.batch.listener.StepMonitorListener; import com.loopers.domain.ranking.MvProductRankMonthly; import com.loopers.domain.ranking.ProductAggregation; -import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; @@ -16,16 +15,16 @@ import org.springframework.batch.core.launch.support.RunIdIncrementer; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; import org.springframework.batch.item.database.JdbcCursorItemReader; -import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; -import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; import org.springframework.batch.support.transaction.ResourcelessTransactionManager; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import javax.sql.DataSource; import java.time.LocalDateTime; @@ -48,6 +47,8 @@ public class MonthlyRankingJobConfig { // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) private static final String MONTHLY_METRICS_SQL = """ SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, SUM(like_count) AS total_like, SUM(order_count) AS total_order, SUM(view_count) AS total_view, @@ -57,8 +58,7 @@ public class MonthlyRankingJobConfig { AND metric_hour < ? AND deleted_at IS NULL GROUP BY product_id - ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 - + 0.7 * LOG(1 + SUM(sales_amount))) DESC + ORDER BY score DESC LIMIT 100 """; @@ -67,7 +67,6 @@ ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 private final StepMonitorListener stepMonitorListener; private final TruncateMonthlyMvTasklet truncateMonthlyMvTasklet; private final MonthlyRankingProcessor monthlyRankingProcessor; - private final EntityManagerFactory entityManagerFactory; private final DataSource dataSource; @Bean(JOB_NAME) @@ -96,7 +95,7 @@ public Step truncateMonthlyMvStep() { @Bean(STEP_AGGREGATE_AND_RANK) public Step monthlyAggregateAndRankStep() { return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) - .chunk(CHUNK_SIZE, new JpaTransactionManager(entityManagerFactory)) + .chunk(CHUNK_SIZE, new DataSourceTransactionManager(dataSource)) .reader(monthlyMetricsItemReader(null)) .processor(monthlyRankingProcessor) .writer(monthlyRankingItemWriter()) @@ -124,6 +123,7 @@ public JdbcCursorItemReader monthlyMetricsItemReader( }) .rowMapper((rs, rowNum) -> new ProductAggregation( rs.getLong("product_id"), + rs.getDouble("score"), rs.getLong("total_like"), rs.getLong("total_order"), rs.getLong("total_view"), @@ -133,9 +133,15 @@ public JdbcCursorItemReader monthlyMetricsItemReader( } @Bean - public JpaItemWriter monthlyRankingItemWriter() { - return new JpaItemWriterBuilder() - .entityManagerFactory(entityManagerFactory) + public JdbcBatchItemWriter monthlyRankingItemWriter() { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_monthly + (product_id, rank, score, total_like, total_order, total_view, total_sales, base_date, created_at) + VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) + """) + .beanMapped() .build(); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java index de52c1203c..a1176a7d4b 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java @@ -33,11 +33,8 @@ public void setTargetYearMonth(String targetYearMonth) { @Override public MvProductRankMonthly process(ProductAggregation item) { int rank = rankCounter.incrementAndGet(); - double score = item.totalView() * 0.1 - + item.totalLike() * 0.2 - + 0.7 * Math.log1p(item.totalSales()); return MvProductRankMonthly.of( - item.productId(), rank, score, + item.productId(), rank, item.score(), item.totalLike(), item.totalOrder(), item.totalView(), item.totalSales(), baseDate ); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java index 85b863f280..c5da650b76 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -6,7 +6,6 @@ import com.loopers.batch.listener.StepMonitorListener; import com.loopers.domain.ranking.MvProductRankWeekly; import com.loopers.domain.ranking.ProductAggregation; -import jakarta.persistence.EntityManagerFactory; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; @@ -16,16 +15,16 @@ import org.springframework.batch.core.launch.support.RunIdIncrementer; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcBatchItemWriter; import org.springframework.batch.item.database.JdbcCursorItemReader; -import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcBatchItemWriterBuilder; import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; -import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; import org.springframework.batch.support.transaction.ResourcelessTransactionManager; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; import javax.sql.DataSource; import java.time.LocalDate; @@ -47,6 +46,8 @@ public class WeeklyRankingJobConfig { // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) private static final String WEEKLY_METRICS_SQL = """ SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, SUM(like_count) AS total_like, SUM(order_count) AS total_order, SUM(view_count) AS total_view, @@ -56,8 +57,7 @@ public class WeeklyRankingJobConfig { AND metric_hour < ? AND deleted_at IS NULL GROUP BY product_id - ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 - + 0.7 * LOG(1 + SUM(sales_amount))) DESC + ORDER BY score DESC LIMIT 100 """; @@ -66,7 +66,6 @@ ORDER BY (SUM(like_count) * 0.2 + SUM(view_count) * 0.1 private final StepMonitorListener stepMonitorListener; private final TruncateWeeklyMvTasklet truncateWeeklyMvTasklet; private final WeeklyRankingProcessor weeklyRankingProcessor; - private final EntityManagerFactory entityManagerFactory; private final DataSource dataSource; @Bean(JOB_NAME) @@ -95,7 +94,7 @@ public Step truncateWeeklyMvStep() { @Bean(STEP_AGGREGATE_AND_RANK) public Step weeklyAggregateAndRankStep() { return new StepBuilder(STEP_AGGREGATE_AND_RANK, jobRepository) - .chunk(CHUNK_SIZE, new JpaTransactionManager(entityManagerFactory)) + .chunk(CHUNK_SIZE, new DataSourceTransactionManager(dataSource)) .reader(weeklyMetricsItemReader(null)) .processor(weeklyRankingProcessor) .writer(weeklyRankingItemWriter()) @@ -125,6 +124,7 @@ public JdbcCursorItemReader weeklyMetricsItemReader( }) .rowMapper((rs, rowNum) -> new ProductAggregation( rs.getLong("product_id"), + rs.getDouble("score"), rs.getLong("total_like"), rs.getLong("total_order"), rs.getLong("total_view"), @@ -134,9 +134,15 @@ public JdbcCursorItemReader weeklyMetricsItemReader( } @Bean - public JpaItemWriter weeklyRankingItemWriter() { - return new JpaItemWriterBuilder() - .entityManagerFactory(entityManagerFactory) + public JdbcBatchItemWriter weeklyRankingItemWriter() { + return new JdbcBatchItemWriterBuilder() + .dataSource(dataSource) + .sql(""" + INSERT INTO mv_product_rank_weekly + (product_id, rank, score, total_like, total_order, total_view, total_sales, base_date, created_at) + VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) + """) + .beanMapped() .build(); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java index 9912126920..31c6cc9915 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java @@ -30,11 +30,8 @@ public void setTargetDate(LocalDate targetDate) { @Override public MvProductRankWeekly process(ProductAggregation item) { int rank = rankCounter.incrementAndGet(); - double score = item.totalView() * 0.1 - + item.totalLike() * 0.2 - + 0.7 * Math.log1p(item.totalSales()); return MvProductRankWeekly.of( - item.productId(), rank, score, + item.productId(), rank, item.score(), item.totalLike(), item.totalOrder(), item.totalView(), item.totalSales(), targetDate ); diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java index d96b6b4f64..d6dfcd2f92 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthly.java @@ -1,49 +1,20 @@ package com.loopers.domain.ranking; -import jakarta.persistence.*; -import lombok.AccessLevel; import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.time.ZonedDateTime; @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Table(name = "mv_product_rank_monthly") public class MvProductRankMonthly { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "product_id", nullable = false) - private Long productId; - - @Column(name = "rank", nullable = false) - private int rank; - - @Column(name = "score", nullable = false) - private double score; - - @Column(name = "total_like", nullable = false) - private long totalLike; - - @Column(name = "total_order", nullable = false) - private long totalOrder; - - @Column(name = "total_view", nullable = false) - private long totalView; - - @Column(name = "total_sales", nullable = false) - private long totalSales; - - @Column(name = "base_date", nullable = false) - private LocalDate baseDate; - - @Column(name = "created_at", nullable = false, updatable = false) - private ZonedDateTime createdAt; + private final Long productId; + private final int rank; + private final double score; + private final long totalLike; + private final long totalOrder; + private final long totalView; + private final long totalSales; + private final LocalDate baseDate; private MvProductRankMonthly(Long productId, int rank, double score, long totalLike, long totalOrder, long totalView, @@ -63,9 +34,4 @@ public static MvProductRankMonthly of(Long productId, int rank, double score, long totalSales, LocalDate baseDate) { return new MvProductRankMonthly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); } - - @PrePersist - private void prePersist() { - this.createdAt = ZonedDateTime.now(); - } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java index b347281f21..e7a9875c4c 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeekly.java @@ -1,49 +1,20 @@ package com.loopers.domain.ranking; -import jakarta.persistence.*; -import lombok.AccessLevel; import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.time.ZonedDateTime; @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -@Table(name = "mv_product_rank_weekly") public class MvProductRankWeekly { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "product_id", nullable = false) - private Long productId; - - @Column(name = "rank", nullable = false) - private int rank; - - @Column(name = "score", nullable = false) - private double score; - - @Column(name = "total_like", nullable = false) - private long totalLike; - - @Column(name = "total_order", nullable = false) - private long totalOrder; - - @Column(name = "total_view", nullable = false) - private long totalView; - - @Column(name = "total_sales", nullable = false) - private long totalSales; - - @Column(name = "base_date", nullable = false) - private LocalDate baseDate; - - @Column(name = "created_at", nullable = false, updatable = false) - private ZonedDateTime createdAt; + private final Long productId; + private final int rank; + private final double score; + private final long totalLike; + private final long totalOrder; + private final long totalView; + private final long totalSales; + private final LocalDate baseDate; private MvProductRankWeekly(Long productId, int rank, double score, long totalLike, long totalOrder, long totalView, @@ -63,9 +34,4 @@ public static MvProductRankWeekly of(Long productId, int rank, double score, long totalSales, LocalDate baseDate) { return new MvProductRankWeekly(productId, rank, score, totalLike, totalOrder, totalView, totalSales, baseDate); } - - @PrePersist - private void prePersist() { - this.createdAt = ZonedDateTime.now(); - } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java index 01818f0f18..cb3ad0a752 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java @@ -2,6 +2,7 @@ public record ProductAggregation( Long productId, + Double score, Long totalLike, Long totalOrder, Long totalView, From 79a962164f244da63efb1915d2f4e72de02e11ce Mon Sep 17 00:00:00 2001 From: Seonmin Date: Fri, 17 Apr 2026 17:11:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20rank=20=EB=B6=80=EC=97=AC=EB=A5=BC=20AtomicInteger?= =?UTF-8?q?=EC=97=90=EC=84=9C=20SQL=20ROW=5FNUMBER()=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chunk retry 시 AtomicInteger 누적으로 rank가 어긋나는 버그를 수정. ROW_NUMBER() OVER 서브쿼리로 DB가 rank를 결정하도록 변경하고, MySQL 예약어 충돌 방지를 위해 rank 컬럼에 백틱 적용. Co-Authored-By: Claude Sonnet 4.6 --- .../monthly/MonthlyRankingJobConfig.java | 34 +++++++++++-------- .../monthly/step/MonthlyRankingProcessor.java | 7 +--- .../weekly/WeeklyRankingJobConfig.java | 34 +++++++++++-------- .../weekly/step/WeeklyRankingProcessor.java | 7 +--- .../domain/ranking/ProductAggregation.java | 1 + 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java index 49c7a7320c..a4cf53e37b 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -47,19 +47,24 @@ public class MonthlyRankingJobConfig { // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) private static final String MONTHLY_METRICS_SQL = """ SELECT product_id, - SUM(like_count) * 0.2 + SUM(view_count) * 0.1 - + 0.7 * LOG(1 + SUM(sales_amount)) AS score, - SUM(like_count) AS total_like, - SUM(order_count) AS total_order, - SUM(view_count) AS total_view, - SUM(sales_amount) AS total_sales - FROM product_metrics - WHERE metric_hour >= ? - AND metric_hour < ? - AND deleted_at IS NULL - GROUP BY product_id - ORDER BY score DESC - LIMIT 100 + ROW_NUMBER() OVER (ORDER BY score DESC) AS `rank`, + score, total_like, total_order, total_view, total_sales + FROM ( + SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + ) sub """; private final JobRepository jobRepository; @@ -123,6 +128,7 @@ public JdbcCursorItemReader monthlyMetricsItemReader( }) .rowMapper((rs, rowNum) -> new ProductAggregation( rs.getLong("product_id"), + rs.getInt("rank"), rs.getDouble("score"), rs.getLong("total_like"), rs.getLong("total_order"), @@ -138,7 +144,7 @@ public JdbcBatchItemWriter monthlyRankingItemWriter() { .dataSource(dataSource) .sql(""" INSERT INTO mv_product_rank_monthly - (product_id, rank, score, total_like, total_order, total_view, total_sales, base_date, created_at) + (product_id, `rank`, score, total_like, total_order, total_view, total_sales, base_date, created_at) VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) """) .beanMapped() diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java index a1176a7d4b..bfe15a881e 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyRankingProcessor.java @@ -12,16 +12,12 @@ import java.time.LocalDate; import java.time.YearMonth; import java.time.format.DateTimeFormatter; -import java.util.concurrent.atomic.AtomicInteger; @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) @Component public class MonthlyRankingProcessor implements ItemProcessor { - // Step 생명주기 동안 rank 순번을 유지 (@StepScope 이므로 Step 종료 시 폐기) - private final AtomicInteger rankCounter = new AtomicInteger(0); - // baseDate 는 해당 월의 1일로 저장 (예: 202604 → 2026-04-01) private LocalDate baseDate; @@ -32,9 +28,8 @@ public void setTargetYearMonth(String targetYearMonth) { @Override public MvProductRankMonthly process(ProductAggregation item) { - int rank = rankCounter.incrementAndGet(); return MvProductRankMonthly.of( - item.productId(), rank, item.score(), + item.productId(), item.rank(), item.score(), item.totalLike(), item.totalOrder(), item.totalView(), item.totalSales(), baseDate ); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java index c5da650b76..8fa4a858de 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -46,19 +46,24 @@ public class WeeklyRankingJobConfig { // 파라미터 1: 집계 시작(inclusive), 파라미터 2: 집계 종료(exclusive) private static final String WEEKLY_METRICS_SQL = """ SELECT product_id, - SUM(like_count) * 0.2 + SUM(view_count) * 0.1 - + 0.7 * LOG(1 + SUM(sales_amount)) AS score, - SUM(like_count) AS total_like, - SUM(order_count) AS total_order, - SUM(view_count) AS total_view, - SUM(sales_amount) AS total_sales - FROM product_metrics - WHERE metric_hour >= ? - AND metric_hour < ? - AND deleted_at IS NULL - GROUP BY product_id - ORDER BY score DESC - LIMIT 100 + ROW_NUMBER() OVER (ORDER BY score DESC) AS `rank`, + score, total_like, total_order, total_view, total_sales + FROM ( + SELECT product_id, + SUM(like_count) * 0.2 + SUM(view_count) * 0.1 + + 0.7 * LOG(1 + SUM(sales_amount)) AS score, + SUM(like_count) AS total_like, + SUM(order_count) AS total_order, + SUM(view_count) AS total_view, + SUM(sales_amount) AS total_sales + FROM product_metrics + WHERE metric_hour >= ? + AND metric_hour < ? + AND deleted_at IS NULL + GROUP BY product_id + ORDER BY score DESC + LIMIT 100 + ) sub """; private final JobRepository jobRepository; @@ -124,6 +129,7 @@ public JdbcCursorItemReader weeklyMetricsItemReader( }) .rowMapper((rs, rowNum) -> new ProductAggregation( rs.getLong("product_id"), + rs.getInt("rank"), rs.getDouble("score"), rs.getLong("total_like"), rs.getLong("total_order"), @@ -139,7 +145,7 @@ public JdbcBatchItemWriter weeklyRankingItemWriter() { .dataSource(dataSource) .sql(""" INSERT INTO mv_product_rank_weekly - (product_id, rank, score, total_like, total_order, total_view, total_sales, base_date, created_at) + (product_id, `rank`, score, total_like, total_order, total_view, total_sales, base_date, created_at) VALUES (:productId, :rank, :score, :totalLike, :totalOrder, :totalView, :totalSales, :baseDate, NOW()) """) .beanMapped() diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java index 31c6cc9915..fd074b97fd 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyRankingProcessor.java @@ -10,16 +10,12 @@ import org.springframework.stereotype.Component; import java.time.LocalDate; -import java.util.concurrent.atomic.AtomicInteger; @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) @Component public class WeeklyRankingProcessor implements ItemProcessor { - // Step 생명주기 동안 rank 순번을 유지 (@StepScope 이므로 Step 종료 시 폐기) - private final AtomicInteger rankCounter = new AtomicInteger(0); - private LocalDate targetDate; @Value("#{jobParameters['targetDate']}") @@ -29,9 +25,8 @@ public void setTargetDate(LocalDate targetDate) { @Override public MvProductRankWeekly process(ProductAggregation item) { - int rank = rankCounter.incrementAndGet(); return MvProductRankWeekly.of( - item.productId(), rank, item.score(), + item.productId(), item.rank(), item.score(), item.totalLike(), item.totalOrder(), item.totalView(), item.totalSales(), targetDate ); diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java index cb3ad0a752..e3d5aabdeb 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductAggregation.java @@ -2,6 +2,7 @@ public record ProductAggregation( Long productId, + int rank, Double score, Long totalLike, Long totalOrder,