From fb8c99b79095b1bc50ba7b8b40abb7932544edf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:02:46 +0900 Subject: [PATCH 01/18] =?UTF-8?q?chore:=20Demo=20Batch=20Job=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/batch/job/demo/DemoJobConfig.java | 48 ------------ .../batch/job/demo/step/DemoTasklet.java | 32 -------- .../com/loopers/job/demo/DemoJobE2ETest.java | 76 ------------------- 3 files changed, 156 deletions(-) delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java delete mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java deleted file mode 100644 index 7c486483f5..0000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.batch.job.demo; - -import com.loopers.batch.job.demo.step.DemoTasklet; -import com.loopers.batch.listener.JobListener; -import com.loopers.batch.listener.StepMonitorListener; -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.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.support.transaction.ResourcelessTransactionManager; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor -@Configuration -public class DemoJobConfig { - public static final String JOB_NAME = "demoJob"; - private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; - - private final JobRepository jobRepository; - private final JobListener jobListener; - private final StepMonitorListener stepMonitorListener; - private final DemoTasklet demoTasklet; - - @Bean(JOB_NAME) - public Job demoJob() { - return new JobBuilder(JOB_NAME, jobRepository) - .incrementer(new RunIdIncrementer()) - .start(categorySyncStep()) - .listener(jobListener) - .build(); - } - - @JobScope - @Bean(STEP_DEMO_SIMPLE_TASK_NAME) - public Step categorySyncStep() { - return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) - .tasklet(demoTasklet, new ResourcelessTransactionManager()) - .listener(stepMonitorListener) - .build(); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java deleted file mode 100644 index 800fe5a03c..0000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.batch.job.demo.step; - -import com.loopers.batch.job.demo.DemoJobConfig; -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.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@StepScope -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor -@Component -public class DemoTasklet implements Tasklet { - @Value("#{jobParameters['requestDate']}") - private String requestDate; - - @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { - if (requestDate == null) { - throw new RuntimeException("requestDate is null"); - } - System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); - Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); - return RepeatStatus.FINISHED; - } -} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java deleted file mode 100644 index dafe59a18c..0000000000 --- a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.loopers.job.demo; - -import com.loopers.batch.job.demo.DemoJobConfig; -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.test.context.TestPropertySource; - -import java.time.LocalDate; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@SpringBootTest -@SpringBatchTest -@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) -class DemoJobE2ETest { - - // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. - // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. - @Autowired - private JobLauncherTestUtils jobLauncherTestUtils; - - @Autowired - @Qualifier(DemoJobConfig.JOB_NAME) - private Job job; - - @BeforeEach - void beforeEach() { - - } - - @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") - @Test - void shouldNotSaveCategories_whenApiError() throws Exception { - // arrange - jobLauncherTestUtils.setJob(job); - - // act - var jobExecution = jobLauncherTestUtils.launchJob(); - - // assert - assertAll( - () -> assertThat(jobExecution).isNotNull(), - () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) - ); - } - - @DisplayName("demoJob 배치가 정상적으로 실행된다.") - @Test - void success() throws Exception { - // arrange - jobLauncherTestUtils.setJob(job); - - // act - var jobParameters = new JobParametersBuilder() - .addLocalDate("requestDate", LocalDate.now()) - .toJobParameters(); - var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); - - // assert - assertAll( - () -> assertThat(jobExecution).isNotNull(), - () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) - ); - } -} From 9db58e63a96457e28c0119fedd1e3fc8e5d5cea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:02:50 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20MV=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Repo?= =?UTF-8?q?sitory=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/MvProductRankWeeklyModel.java | 55 +++++++++++++++++++ .../MvProductRankWeeklyJpaRepository.java | 15 +++++ 2 files changed, 70 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java new file mode 100644 index 0000000000..462a7f3bfd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +/** + * 주간 TOP 100 랭킹 Materialized View. + * 배치 Job이 product_metrics에서 가중치 점수를 계산하여 적재한다. + * 흐름: product_metrics → Batch(Reader/Processor/Writer) → 이 테이블 → API 조회 + */ +@Entity +@Table(name = "mv_product_rank_weekly", indexes = { + @Index(name = "idx_mv_weekly_year_week_ranking", columnList = "year_week, `ranking`") +}) +@Getter +public class MvProductRankWeeklyModel extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; // viewCount * 0.1 + likeCount * 0.2 + salesCount * 0.7 + + @Column(name = "`ranking`", nullable = false) + private int ranking; // 1-based 순위 + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; // e.g., "2026W15" + + protected MvProductRankWeeklyModel() {} + + public MvProductRankWeeklyModel(Long productId, long viewCount, long likeCount, + long salesCount, double score, int ranking, String yearWeek) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.ranking = ranking; + this.yearWeek = yearWeek; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..12c6c206e0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + /** 특정 주차의 기존 랭킹 데이터를 일괄 삭제 (배치 재실행 대비) */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MvProductRankWeeklyModel m WHERE m.yearWeek = :yearWeek") + void deleteByYearWeek(@Param("yearWeek") String yearWeek); +} From 8abb65304a10f58f20b5d1705ff06ae3b4849251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:02:54 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20MV=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Repo?= =?UTF-8?q?sitory=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/MvProductRankMonthlyModel.java | 50 +++++++++++++++++++ .../MvProductRankMonthlyJpaRepository.java | 14 ++++++ 2 files changed, 64 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java new file mode 100644 index 0000000000..2eea4f2fbc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java @@ -0,0 +1,50 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "mv_product_rank_monthly", indexes = { + @Index(name = "idx_mv_monthly_year_month_ranking", columnList = "`year_month`, `ranking`") +}) +@Getter +public class MvProductRankMonthlyModel extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "`year_month`", nullable = false, length = 10) + private String yearMonth; // e.g., "202604" + + protected MvProductRankMonthlyModel() {} + + public MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, + long salesCount, double score, int ranking, String yearMonth) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.ranking = ranking; + this.yearMonth = yearMonth; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..839c248ba7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthlyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MvProductRankMonthlyModel m WHERE m.yearMonth = :yearMonth") + void deleteByYearMonth(@Param("yearMonth") String yearMonth); +} From 781df414b283d0824a1d3fa337a25638ce22c9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:02:58 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20Clear=20Tasklet=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/ClearWeeklyRankingTasklet.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java new file mode 100644 index 0000000000..c507d3051d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; + +/** + * 주간 MV 테이블에서 해당 주차의 기존 데이터를 삭제하는 Tasklet. + * 배치 재실행 시 중복 데이터 적재를 방지한다. + * 흐름: Job 파라미터(requestDate) → yearWeek 계산 → DELETE + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class ClearWeeklyRankingTasklet implements Tasklet { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + // Job 파라미터에서 실행 기준 날짜를 주입받는다 + // @StepScope가 있어야 #{jobParameters[...]} late binding이 동작한다 + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearWeek = computeYearWeek(requestDate); + log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek); + + weeklyJpaRepository.deleteByYearWeek(yearWeek); + + return RepeatStatus.FINISHED; + } + + /** + * "yyyyMMdd" 형식의 날짜에서 ISO 주차를 계산한다. + * 예: "20260412" → 2026년 15주차 → "2026W15" + */ + public static String computeYearWeek(String dateStr) { + LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + int year = date.get(WeekFields.ISO.weekBasedYear()); + int week = date.get(WeekFields.ISO.weekOfWeekBasedYear()); + return String.format("%dW%02d", year, week); + } +} From ffad6f05b6f8e272a59652b6208ba08f3d8fbd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:02 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20Batch=20Job=20=EA=B5=AC=EC=84=B1=20(Chunk=20Process?= =?UTF-8?q?ing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../weekly/WeeklyRankingJobConfig.java | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java 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..0aa19996ef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -0,0 +1,177 @@ +package com.loopers.batch.job.ranking.weekly; + +import com.loopers.batch.job.ranking.ProductMetricsRow; +import com.loopers.batch.job.ranking.weekly.step.ClearWeeklyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +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.ItemProcessor; +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.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.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 주간 랭킹 집계 배치 Job 설정. + * + * Job 구조: + * Step 1 (Tasklet) — 해당 주차 기존 MV 데이터 삭제 + * Step 2 (Chunk) — product_metrics 읽기 → 점수 계산/순위 부여 → MV 적재 + * + * 실행: + * ./gradlew :apps:commerce-batch:bootRun \ + * --args="--spring.batch.job.name=weeklyRankingJob --job.name=weeklyRankingJob --requestDate=20260412" + */ +@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_CLEAR = "clearWeeklyRankingStep"; + private static final String STEP_AGGREGATE = "aggregateWeeklyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ClearWeeklyRankingTasklet clearWeeklyRankingTasklet; + + // ============================================================ + // Job 정의 + // ============================================================ + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(clearWeeklyRankingStep()) // Step 1: 기존 데이터 삭제 + .next(aggregateWeeklyRankingStep()) // Step 2: 집계 + 적재 + .listener(jobListener) + .build(); + } + + // ============================================================ + // Step 1: 기존 MV 데이터 삭제 (Tasklet) + // ============================================================ + + @JobScope + @Bean(STEP_CLEAR) + public Step clearWeeklyRankingStep() { + return new StepBuilder(STEP_CLEAR, jobRepository) + .tasklet(clearWeeklyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + // ============================================================ + // Step 2: 집계 + 적재 (Chunk-Oriented) + // ============================================================ + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateWeeklyRankingStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader(null)) // DataSource는 Spring이 주입 + .processor(weeklyRankingProcessor(null)) // requestDate는 @StepScope에서 주입 + .writer(weeklyRankingWriter(null)) // EntityManagerFactory는 Spring이 주입 + .listener(stepMonitorListener) + .build(); + } + + // ---- Reader: product_metrics에서 가중치 점수 기준 TOP 100 조회 ---- + + /** + * JdbcCursorItemReader: DB 커서를 열고 1행씩 읽는다. + * commerce-batch에는 ProductMetricsModel 엔티티가 없으므로 JDBC로 직접 읽는다. + * + * SQL 설명: + * - product_metrics의 모든 행에 가중치 점수를 계산 + * - 점수 내림차순으로 정렬하여 상위 100건만 조회 + * - 가중치: view * 0.1 + like * 0.2 + salesCount * 0.7 + */ + @StepScope + @Bean + public JdbcCursorItemReader weeklyRankingReader(DataSource dataSource) { + return new JdbcCursorItemReaderBuilder() + .name("weeklyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, + pm.view_count, + pm.like_count, + pm.sales_count, + (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score + FROM product_metrics pm + ORDER BY score DESC + LIMIT 100 + """) + .rowMapper((rs, rowNum) -> new ProductMetricsRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count"), + rs.getDouble("score"))) + .build(); + } + + // ---- Processor: 점수 계산 + yearWeek 계산 + 순위 부여 ---- + + /** + * ProductMetricsRow → MvProductRankWeeklyModel 변환. + * - requestDate로부터 yearWeek을 계산한다 + * - rankCounter로 읽은 순서대로 순위를 부여한다 (Reader가 점수 DESC로 정렬했으므로) + * + * @StepScope: Step마다 새 인스턴스 생성 → rankCounter가 0으로 리셋됨 + */ + @StepScope + @Bean + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearWeek = ClearWeeklyRankingTasklet.computeYearWeek(requestDate); + + return item -> new MvProductRankWeeklyModel( + item.productId(), + item.viewCount(), + item.likeCount(), + item.salesCount(), + item.score(), + rankCounter.incrementAndGet(), // 1, 2, 3, ... 순위 부여 + yearWeek + ); + } + + // ---- Writer: MV 테이블에 JPA로 저장 ---- + + @StepScope + @Bean + public JpaItemWriter weeklyRankingWriter( + EntityManagerFactory entityManagerFactory + ) { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} From 933b4cb78e9693bd2ccbb86881938f6e12080619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:06 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20Clear=20Tasklet=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/ClearMonthlyRankingTasklet.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java new file mode 100644 index 0000000000..a45195e43d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java @@ -0,0 +1,40 @@ +package com.loopers.batch.job.ranking.monthly.step; + +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class ClearMonthlyRankingTasklet implements Tasklet { + + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearMonth = computeYearMonth(requestDate); + log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth); + monthlyJpaRepository.deleteByYearMonth(yearMonth); + return RepeatStatus.FINISHED; + } + + /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */ + public static String computeYearMonth(String dateStr) { + return dateStr.substring(0, 6); + } +} From d7652e0d0d43cf014145302d78fb1a36eb951dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:11 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20Batch=20Job=20=EA=B5=AC=EC=84=B1=20(Chunk=20Process?= =?UTF-8?q?ing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monthly/MonthlyRankingJobConfig.java | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.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..23aac35af9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -0,0 +1,134 @@ +package com.loopers.batch.job.ranking.monthly; + +import com.loopers.batch.job.ranking.ProductMetricsRow; +import com.loopers.batch.job.ranking.monthly.step.ClearMonthlyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthlyModel; +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.ItemProcessor; +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.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.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.concurrent.atomic.AtomicInteger; + +@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_CLEAR = "clearMonthlyRankingStep"; + private static final String STEP_AGGREGATE = "aggregateMonthlyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ClearMonthlyRankingTasklet clearMonthlyRankingTasklet; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(clearMonthlyRankingStep()) + .next(aggregateMonthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEAR) + public Step clearMonthlyRankingStep() { + return new StepBuilder(STEP_CLEAR, jobRepository) + .tasklet(clearMonthlyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateMonthlyRankingStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + // Reader — 주간과 동일한 SQL (product_metrics에서 TOP 100) + @StepScope + @Bean + public JdbcCursorItemReader monthlyRankingReader(DataSource dataSource) { + return new JdbcCursorItemReaderBuilder() + .name("monthlyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, + pm.view_count, + pm.like_count, + pm.sales_count, + (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score + FROM product_metrics pm + ORDER BY score DESC + LIMIT 100 + """) + .rowMapper((rs, rowNum) -> new ProductMetricsRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count"), + rs.getDouble("score"))) + .build(); + } + + // Processor — yearMonth 계산 + 순위 부여 (차이점: yearWeek 대신 yearMonth) + @StepScope + @Bean + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearMonth = ClearMonthlyRankingTasklet.computeYearMonth(requestDate); + + return item -> new MvProductRankMonthlyModel( + item.productId(), + item.viewCount(), + item.likeCount(), + item.salesCount(), + item.score(), + rankCounter.incrementAndGet(), + yearMonth + ); + } + + @StepScope + @Bean + public JpaItemWriter monthlyRankingWriter( + EntityManagerFactory entityManagerFactory + ) { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} From 4cb6777c250d72d20773c84f9cd5c40a14f59763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:16 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20MV=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/MvProductRankWeeklyReadModel.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java new file mode 100644 index 0000000000..8f6e69291a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java @@ -0,0 +1,44 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import org.hibernate.annotations.Immutable; + +/** + * 주간 MV 읽기 전용 엔티티 (commerce-api용). + * commerce-batch에서 적재한 mv_product_rank_weekly 테이블을 조회만 한다. + * @Immutable: Hibernate의 dirty checking 대상에서 제외 (읽기 전용 최적화) + */ +@Entity +@Immutable +@Table(name = "mv_product_rank_weekly") +@Getter +public class MvProductRankWeeklyReadModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + protected MvProductRankWeeklyReadModel() {} +} From 3be29f795375f31df023a02dcd8d6e1dcc5374a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:21 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20MV=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MvProductRankMonthlyReadModel.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java new file mode 100644 index 0000000000..6cc2805583 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import org.hibernate.annotations.Immutable; + +@Entity +@Immutable +@Table(name = "mv_product_rank_monthly") +@Getter +public class MvProductRankMonthlyReadModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "`year_month`", nullable = false, length = 10) + private String yearMonth; + + protected MvProductRankMonthlyReadModel() {} +} From 3f247eb115a9ccae2d95b16e763d41d208bd802c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:25 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ranking/RankingRepository.java | 6 ++++++ .../domain/ranking/RankingService.java | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java index b9a13060d9..918bbb414c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -12,4 +12,10 @@ public interface RankingRepository { /** DB ORDER BY 기반 Top-N 조회 — ZSET 성능 비교용 */ List getTopNFromDB(int offset, int size); + + /** MV 주간 랭킹 조회 — yearWeek(예: "2026W15") 기준 */ + List getTopNWeekly(String yearWeek, int offset, int size); + + /** MV 월간 랭킹 조회 — yearMonth(예: "202604") 기준 */ + List getTopNMonthly(String yearMonth, int offset, int size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index f60ea5ae68..ce235ace3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -47,4 +47,25 @@ public Long getProductRank(Long productId) { Long rank = rankingRepository.getRank(key, productId); return rank != null ? rank + 1 : null; // 0-based → 1-based } + + /** 주간 랭킹 조회: date(yyyyMMdd)에서 yearWeek를 계산하여 MV 테이블 조회 */ + public List getTopRankingsWeekly(String date, int page, int size) { + String yearWeek = computeYearWeek(date); + int offset = (page - 1) * size; + return rankingRepository.getTopNWeekly(yearWeek, offset, size); + } + + /** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */ + public List getTopRankingsMonthly(String date, int page, int size) { + String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM" + int offset = (page - 1) * size; + return rankingRepository.getTopNMonthly(yearMonth, offset, size); + } + + private String computeYearWeek(String dateStr) { + LocalDate date = LocalDate.parse(dateStr, DATE_FORMAT); + int year = date.get(java.time.temporal.WeekFields.ISO.weekBasedYear()); + int week = date.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear()); + return String.format("%dW%02d", year, week); + } } From d37ea7b4cb59423221d017670f154c798ee98152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:03:30 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MvProductRankMonthlyJpaRepository.java | 13 ++++++++++++ .../MvProductRankWeeklyJpaRepository.java | 13 ++++++++++++ .../ranking/RankingRedisRepository.java | 21 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..862dfe055a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthlyReadModel; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + /** 특정 월의 랭킹을 순위순으로 페이징 조회 */ + List findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..eab6daa134 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeeklyReadModel; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + /** 특정 주차의 랭킹을 순위순으로 페이징 조회 */ + List findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java index c60be33d88..6a10329551 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java @@ -3,6 +3,7 @@ import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; @@ -16,6 +17,8 @@ public class RankingRedisRepository implements RankingRepository { private final RedisTemplate redisTemplate; private final ProductMetricsJpaRepository productMetricsJpaRepository; + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; @Override public List getTopN(String key, int offset, int size) { @@ -53,4 +56,22 @@ public List getTopNFromDB(int offset, int size) { ((Number) row[1]).doubleValue())) .toList(); } + + @Override + public List getTopNWeekly(String yearWeek, int offset, int size) { + int page = offset / size; + return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size)) + .stream() + .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) + .toList(); + } + + @Override + public List getTopNMonthly(String yearMonth, int offset, int size) { + int page = offset / size; + return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size)) + .stream() + .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) + .toList(); + } } From eb70ab7d2c9a02fb8521664a9b1334bf15154dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:08 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20Facade=20?= =?UTF-8?q?=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 69 +++++++++++++++++-- 1 file changed, 63 insertions(+), 6 deletions(-) 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 60f3397fb2..4381f828c1 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 @@ -28,7 +28,6 @@ public class RankingFacade { @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankings(String date, int page, int size) { // 1. ZSET에서 Top-N 조회 (Redis) - // ZREVRANGE ranking:all:{date} {offset} {offset+size-1} WITHSCORES List entries = rankingService.getTopRankings(date, page, size); if (entries.isEmpty()) { @@ -36,7 +35,6 @@ public RankingInfo.RankingPageResponse getRankings(String date, int page, int si } // 2. productId 목록으로 상품 정보 일괄 조회 (DB) - // ZSET에는 productId만 있으므로, 상품명/가격/이미지 등은 DB에서 조회 List productIds = entries.stream() .map(RankingEntry::productId) .toList(); @@ -44,20 +42,19 @@ public RankingInfo.RankingPageResponse getRankings(String date, int page, int si .collect(Collectors.toMap(ProductModel::getId, Function.identity())); // 3. 브랜드 정보 일괄 조회 (DB) - // 상품의 brandId로 브랜드명을 가져온다 (N+1 방지를 위해 일괄 조회) Set brandIds = productMap.values().stream() .map(ProductModel::getBrandId) .collect(Collectors.toSet()); Map brandMap = brandService.getByIds(brandIds); // 4. ZSET 결과 + 상품 정보 + 브랜드 정보를 조합하여 응답 구성 - int baseRank = (page - 1) * size; // 페이지별 기준 순위 (page=2, size=20이면 20) + int baseRank = (page - 1) * size; List rankings = new ArrayList<>(); for (int i = 0; i < entries.size(); i++) { RankingEntry entry = entries.get(i); ProductModel product = productMap.get(entry.productId()); - if (product == null) continue; // 삭제된 상품은 skip + if (product == null) continue; BrandModel brand = brandMap.get(product.getBrandId()); String brandName = brand != null ? brand.getName() : "Unknown"; @@ -78,7 +75,6 @@ public RankingInfo.RankingPageResponse getRankings(String date, int page, int si /** * DB ORDER BY 기반 랭킹 조회 — ZSET 성능 비교용. - * product_metrics 테이블의 가중치 합산 점수로 정렬 후 상품/브랜드 Aggregation. */ @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankingsFromDB(int page, int size) { @@ -123,4 +119,65 @@ public RankingInfo.RankingPageResponse getRankingsFromDB(int page, int size) { return new RankingInfo.RankingPageResponse(rankings, page, size); } + + /** 주간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */ + @Transactional(readOnly = true) + public RankingInfo.RankingPageResponse getRankingsWeekly(String date, int page, int size) { + List entries = rankingService.getTopRankingsWeekly(date, page, size); + return assembleResponse(entries, page, size); + } + + /** 월간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */ + @Transactional(readOnly = true) + public RankingInfo.RankingPageResponse getRankingsMonthly(String date, int page, int size) { + List entries = rankingService.getTopRankingsMonthly(date, page, size); + return assembleResponse(entries, page, size); + } + + /** + * RankingEntry 목록을 상품/브랜드 정보와 조합하여 응답을 구성한다. + * 일간/주간/월간 모두 동일한 Aggregation 로직을 사용하므로 공통 메서드로 추출. + */ + private RankingInfo.RankingPageResponse assembleResponse( + List entries, int page, int size + ) { + if (entries.isEmpty()) { + return new RankingInfo.RankingPageResponse(List.of(), page, size); + } + + List productIds = entries.stream() + .map(RankingEntry::productId) + .toList(); + Map productMap = productService.getByIds(productIds).stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + + Set brandIds = productMap.values().stream() + .map(ProductModel::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + int baseRank = (page - 1) * size; + List rankings = new ArrayList<>(); + + for (int i = 0; i < entries.size(); i++) { + RankingEntry entry = entries.get(i); + ProductModel product = productMap.get(entry.productId()); + if (product == null) continue; + + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + rankings.add(new RankingInfo.RankingItem( + baseRank + i + 1, + entry.score(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getImageUrl() + )); + } + + return new RankingInfo.RankingPageResponse(rankings, page, size); + } } From 62ccbca49cc7c4bb7f28542729a2886274f67ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:27 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20API=20period?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/ranking/RankingV1ApiSpec.java | 3 ++- .../interfaces/api/ranking/RankingV1Controller.java | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index a2cb8e703e..53cb7d8c3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -8,8 +8,9 @@ @Tag(name = "Ranking V1 API", description = "상품 랭킹 API") public interface RankingV1ApiSpec { - @Operation(summary = "랭킹 조회", description = "날짜별 상품 랭킹을 조회합니다.") + @Operation(summary = "랭킹 조회", description = "기간별 상품 랭킹을 조회합니다. (일간/주간/월간)") ApiResponse getRankings( + @Parameter(description = "기간 (daily/weekly/monthly, 기본 daily)") String period, @Parameter(description = "조회 날짜 (yyyyMMdd), 미지정 시 오늘") String date, @Parameter(description = "페이지 크기 (기본 20)") int size, @Parameter(description = "페이지 번호 (1부터, 기본 1)") int page diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 4e2aaf458c..7c6275e717 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -25,18 +25,22 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override public ApiResponse getRankings( + @RequestParam(defaultValue = "daily") String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { - // date 미지정 시 오늘 날짜를 기본값으로 사용 if (date == null || date.isBlank()) { date = LocalDate.now().format(DATE_FORMAT); } - // Facade에서 ZSET 조회 + 상품/브랜드 Aggregation 수행 - RankingInfo.RankingPageResponse info = rankingFacade.getRankings(date, page, size); - // application 레이어 Info → interfaces 레이어 DTO 변환 + // period에 따라 데이터 소스 분기 + RankingInfo.RankingPageResponse info = switch (period) { + case "weekly" -> rankingFacade.getRankingsWeekly(date, page, size); + case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size); + default -> rankingFacade.getRankings(date, page, size); + }; + return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(info)); } From 1efd47bf98e61e99df1d2982f1014bfdf812b0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:31 +0900 Subject: [PATCH 14/18] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20Facade?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20Aggregation=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 88 ++----------------- 1 file changed, 6 insertions(+), 82 deletions(-) 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 4381f828c1..bf0f7187bb 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 @@ -27,97 +27,18 @@ public class RankingFacade { @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankings(String date, int page, int size) { - // 1. ZSET에서 Top-N 조회 (Redis) List entries = rankingService.getTopRankings(date, page, size); - - if (entries.isEmpty()) { - return new RankingInfo.RankingPageResponse(List.of(), page, size); - } - - // 2. productId 목록으로 상품 정보 일괄 조회 (DB) - List productIds = entries.stream() - .map(RankingEntry::productId) - .toList(); - Map productMap = productService.getByIds(productIds).stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); - - // 3. 브랜드 정보 일괄 조회 (DB) - Set brandIds = productMap.values().stream() - .map(ProductModel::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - // 4. ZSET 결과 + 상품 정보 + 브랜드 정보를 조합하여 응답 구성 - int baseRank = (page - 1) * size; - List rankings = new ArrayList<>(); - - for (int i = 0; i < entries.size(); i++) { - RankingEntry entry = entries.get(i); - ProductModel product = productMap.get(entry.productId()); - if (product == null) continue; - - BrandModel brand = brandMap.get(product.getBrandId()); - String brandName = brand != null ? brand.getName() : "Unknown"; - - rankings.add(new RankingInfo.RankingItem( - baseRank + i + 1, - entry.score(), - product.getId(), - product.getName(), - brandName, - product.getPrice(), - product.getImageUrl() - )); - } - - return new RankingInfo.RankingPageResponse(rankings, page, size); + return assembleResponse(entries, page, size); } /** * DB ORDER BY 기반 랭킹 조회 — ZSET 성능 비교용. + * product_metrics 테이블의 가중치 합산 점수로 정렬 후 상품/브랜드 Aggregation. */ @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankingsFromDB(int page, int size) { List entries = rankingService.getTopRankingsFromDB(page, size); - - if (entries.isEmpty()) { - return new RankingInfo.RankingPageResponse(List.of(), page, size); - } - - List productIds = entries.stream() - .map(RankingEntry::productId) - .toList(); - Map productMap = productService.getByIds(productIds).stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); - - Set brandIds = productMap.values().stream() - .map(ProductModel::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - int baseRank = (page - 1) * size; - List rankings = new ArrayList<>(); - - for (int i = 0; i < entries.size(); i++) { - RankingEntry entry = entries.get(i); - ProductModel product = productMap.get(entry.productId()); - if (product == null) continue; - - BrandModel brand = brandMap.get(product.getBrandId()); - String brandName = brand != null ? brand.getName() : "Unknown"; - - rankings.add(new RankingInfo.RankingItem( - baseRank + i + 1, - entry.score(), - product.getId(), - product.getName(), - brandName, - product.getPrice(), - product.getImageUrl() - )); - } - - return new RankingInfo.RankingPageResponse(rankings, page, size); + return assembleResponse(entries, page, size); } /** 주간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */ @@ -145,17 +66,20 @@ private RankingInfo.RankingPageResponse assembleResponse( return new RankingInfo.RankingPageResponse(List.of(), page, size); } + // 상품 정보 일괄 조회 List productIds = entries.stream() .map(RankingEntry::productId) .toList(); Map productMap = productService.getByIds(productIds).stream() .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + // 브랜드 정보 일괄 조회 Set brandIds = productMap.values().stream() .map(ProductModel::getBrandId) .collect(Collectors.toSet()); Map brandMap = brandService.getByIds(brandIds); + // 응답 조합 int baseRank = (page - 1) * size; List rankings = new ArrayList<>(); From eca84121aa8f7759b061e812ce6b0c397a0467ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:36 +0900 Subject: [PATCH 15/18] =?UTF-8?q?fix:=20=EB=9E=AD=ED=82=B9=20API=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B0=92=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(period,=20page,=20size,=20date)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/ranking/RankingV1Controller.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 7c6275e717..8be2f8b7dd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -3,6 +3,8 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,6 +13,8 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Set; @RequiredArgsConstructor @RestController @@ -21,6 +25,7 @@ public class RankingV1Controller implements RankingV1ApiSpec { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Set VALID_PERIODS = Set.of("daily", "weekly", "monthly"); @GetMapping @Override @@ -30,11 +35,26 @@ public ApiResponse getRankings( @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { + if (!VALID_PERIODS.contains(period)) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다: " + period); + } + if (page < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다."); + } + if (size < 1 || size > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~100 범위여야 합니다."); + } + if (date == null || date.isBlank()) { date = LocalDate.now().format(DATE_FORMAT); + } else { + try { + LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + } } - // period에 따라 데이터 소스 분기 RankingInfo.RankingPageResponse info = switch (period) { case "weekly" -> rankingFacade.getRankingsWeekly(date, page, size); case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size); From 0b117cbed1eaaf391da230051eb0f5437ae8c99e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:40 +0900 Subject: [PATCH 16/18] =?UTF-8?q?test:=20=EB=B0=B0=EC=B9=98=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20product=5Fmetrics=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job/ranking/WeeklyRankingJobE2ETest.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java 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..09f03e0833 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,128 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +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 org.junit.jupiter.api.BeforeEach; + +import java.util.List; + +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 org.springframework.batch.core.Job job; + + @Autowired + private MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // commerce-batch에는 ProductMetricsModel 엔티티가 없어 Hibernate가 테이블을 생성하지 않으므로 직접 생성 + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL UNIQUE, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL + ) ENGINE=InnoDB + """); + jdbcTemplate.execute("DELETE FROM product_metrics"); + weeklyJpaRepository.deleteAll(); + } + + @DisplayName("product_metrics 데이터가 있으면 주간 랭킹 MV가 정상 적재된다") + @Test + void weeklyRankingJob_success() throws Exception { + // given — product_metrics에 테스트 데이터 삽입 + jdbcTemplate.execute(""" + INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, sales_amount, version, updated_at) + VALUES + (1, 100, 50, 10, 1000000, 0, NOW()), + (2, 200, 30, 5, 500000, 0, NOW()), + (3, 50, 100, 20, 2000000, 0, NOW()) + """); + + jobLauncherTestUtils.setJob(job); + + // when — 배치 실행 + var jobParameters = new JobParametersBuilder() + .addString("requestDate", "20260412") + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then — Job 성공 확인 + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // then — MV 데이터 검증 + List results = weeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).getRanking()).isEqualTo(1), + () -> assertThat(results.get(0).getScore()) + .isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수 + () -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15") + ); + } + + @DisplayName("배치 재실행 시 기존 데이터가 삭제되고 새로 적재된다") + @Test + void weeklyRankingJob_rerun_replacesOldData() throws Exception { + // given — product_metrics 데이터 삽입 + jdbcTemplate.execute(""" + INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, sales_amount, version, updated_at) + VALUES (1, 100, 50, 10, 1000000, 0, NOW()) + """); + + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addString("requestDate", "20260412") + .addLong("run.id", 10L) + .toJobParameters(); + + // when — 1차 실행 + jobLauncherTestUtils.launchJob(jobParameters); + + // when — 2차 실행 (동일 requestDate) + var jobParameters2 = new JobParametersBuilder() + .addString("requestDate", "20260412") + .addLong("run.id", 11L) + .toJobParameters(); + var jobExecution2 = jobLauncherTestUtils.launchJob(jobParameters2); + + // then — 중복 없이 1건만 존재 + assertThat(jobExecution2.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + assertThat(weeklyJpaRepository.findAll()).hasSize(1); + } +} From e5296031a66f50a2619e77b26a9626e7b0e8051f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:04:44 +0900 Subject: [PATCH 17/18] =?UTF-8?q?test:=20CommerceBatchApplicationTest=20Jo?= =?UTF-8?q?b=20=EC=9E=90=EB=8F=99=EC=8B=A4=ED=96=89=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/java/com/loopers/CommerceBatchApplicationTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..71a9071861 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} From 6b0e5f1b909b7b01b99744a3ad38325c8d10fbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9D=B8=EC=B2=A0?= Date: Tue, 14 Apr 2026 20:05:01 +0900 Subject: [PATCH 18/18] =?UTF-8?q?feat:=20Batch=20Reader=EC=9A=A9=20Product?= =?UTF-8?q?MetricsRow=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../batch/job/ranking/ProductMetricsRow.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java new file mode 100644 index 0000000000..9c76e18f06 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java @@ -0,0 +1,20 @@ +package com.loopers.batch.job.ranking; + +/** + * product_metrics 테이블의 1행을 담는 읽기 전용 DTO. + * JdbcCursorItemReader의 RowMapper에서 생성된다. + * commerce-batch에는 ProductMetricsModel 엔티티가 없으므로 JDBC + record로 처리. + * + * @param productId 상품 ID + * @param viewCount 누적 조회수 + * @param likeCount 누적 좋아요수 + * @param salesCount 누적 판매건수 + * @param score 가중치 점수 (SQL에서 계산: view*0.1 + like*0.2 + sales*0.7) + */ +public record ProductMetricsRow( + Long productId, + long viewCount, + long likeCount, + long salesCount, + double score +) {}