From 7c9d5f4ce4f67b62c044bb4e1705df2edc8810d5 Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 15:15:54 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20daily=20metrics=20snapshot=20batch?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DailyMetricsSnapshotJobConfig.java | 50 +++++ .../step/DailyMetricsSnapshotTasklet.java | 62 ++++++ .../domain/productmetrics/ProductMetrics.java | 40 ++++ .../ProductMetricsRepository.java | 15 ++ .../ranking/RankingSnapshotRepository.java | 10 + .../ProductMetricsJpaRepository.java | 19 ++ .../ProductMetricsRepositoryImpl.java | 36 ++++ .../RankingSnapshotRedisRepository.java | 41 ++++ .../DailyMetricsSnapshotJobE2ETest.java | 183 ++++++++++++++++++ 9 files changed, 456 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java new file mode 100644 index 0000000000..b8ba418199 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/DailyMetricsSnapshotJobConfig.java @@ -0,0 +1,50 @@ +package com.loopers.batch.job.dailymetrics; + +import com.loopers.batch.job.dailymetrics.step.DailyMetricsSnapshotTasklet; +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DailyMetricsSnapshotJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DailyMetricsSnapshotJobConfig { + + public static final String JOB_NAME = "dailyMetricsSnapshotJob"; + private static final String STEP_NAME = "dailyMetricsSnapshotStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DailyMetricsSnapshotTasklet dailyMetricsSnapshotTasklet; + + @Bean(JOB_NAME) + public Job dailyMetricsSnapshotJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(dailyMetricsSnapshotStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step dailyMetricsSnapshotStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(dailyMetricsSnapshotTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java new file mode 100644 index 0000000000..3308000f0c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -0,0 +1,62 @@ +package com.loopers.batch.job.dailymetrics.step; + +import com.loopers.batch.job.dailymetrics.DailyMetricsSnapshotJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productmetrics.ProductMetricsRepository; +import com.loopers.domain.ranking.RankingSnapshotRepository; +import com.loopers.domain.ranking.RankingSnapshotRepository.ProductScore; +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.util.List; + +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DailyMetricsSnapshotJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DailyMetricsSnapshotTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("#{jobParameters['requestDate']}") + private LocalDate requestDate; + + private final RankingSnapshotRepository rankingSnapshotRepository; + private final ProductMetricsRepository productMetricsRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + + String dateKey = requestDate.format(DATE_FORMAT); + log.info("일간 스냅샷 배치 시작 - requestDate: {}, dateKey: {}", requestDate, dateKey); + + List scores = rankingSnapshotRepository.getAllScores(dateKey); + log.info("Redis에서 조회된 상품 수: {}", scores.size()); + + productMetricsRepository.deleteByMetricDate(requestDate); + + if (!scores.isEmpty()) { + List metricsList = scores.stream() + .map(ps -> new ProductMetrics(ps.productId(), requestDate, ps.score())) + .toList(); + productMetricsRepository.saveAll(metricsList); + log.info("product_metrics 적재 완료 - {} 건", metricsList.size()); + } + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java new file mode 100644 index 0000000000..8c4470e15c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -0,0 +1,40 @@ +package com.loopers.domain.productmetrics; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "product_metrics", + uniqueConstraints = @UniqueConstraint( + name = "uk_product_metrics", + columnNames = {"product_id", "metric_date"} + ), + indexes = @Index(name = "idx_metric_date", columnList = "metric_date") +) +public class ProductMetrics extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "score", nullable = false) + private double score; + + protected ProductMetrics() {} + + public ProductMetrics(Long productId, LocalDate metricDate, double score) { + this.productId = productId; + this.metricDate = metricDate; + this.score = score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java new file mode 100644 index 0000000000..2b0706075f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetricsRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.productmetrics; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsRepository { + + ProductMetrics save(ProductMetrics productMetrics); + + List saveAll(List productMetricsList); + + List findByMetricDate(LocalDate metricDate); + + void deleteByMetricDate(LocalDate metricDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java new file mode 100644 index 0000000000..3b0b5e1b54 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/RankingSnapshotRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +public interface RankingSnapshotRepository { + + List getAllScores(String dateKey); + + record ProductScore(Long productId, double score) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java new file mode 100644 index 0000000000..137fb07ca1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.productmetrics; + +import com.loopers.domain.productmetrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + List findByMetricDate(LocalDate metricDate); + + @Modifying + @Query("DELETE FROM ProductMetrics pm WHERE pm.metricDate = :metricDate") + void deleteByMetricDate(@Param("metricDate") LocalDate metricDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 0000000000..44fc010244 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productmetrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.productmetrics; + +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productmetrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + @Override + public List saveAll(List productMetricsList) { + return productMetricsJpaRepository.saveAll(productMetricsList); + } + + @Override + public List findByMetricDate(LocalDate metricDate) { + return productMetricsJpaRepository.findByMetricDate(metricDate); + } + + @Override + public void deleteByMetricDate(LocalDate metricDate) { + productMetricsJpaRepository.deleteByMetricDate(metricDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java new file mode 100644 index 0000000000..8a44575316 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingSnapshotRepository; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Component +public class RankingSnapshotRedisRepository implements RankingSnapshotRepository { + + private static final String KEY_PREFIX = "ranking:all:"; + + private final RedisTemplate redisTemplate; + + public RankingSnapshotRedisRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public List getAllScores(String dateKey) { + String key = KEY_PREFIX + dateKey; + Set> tuples = + redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, -1); + + if (tuples == null || tuples.isEmpty()) { + return List.of(); + } + + List result = new ArrayList<>(); + for (ZSetOperations.TypedTuple tuple : tuples) { + Long productId = Long.valueOf(tuple.getValue()); + double score = tuple.getScore(); + result.add(new ProductScore(productId, score)); + } + return result; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java new file mode 100644 index 0000000000..ba48282409 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -0,0 +1,183 @@ +package com.loopers.job.dailymetrics; + +import com.loopers.batch.job.dailymetrics.DailyMetricsSnapshotJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.infrastructure.productmetrics.ProductMetricsJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +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=" + DailyMetricsSnapshotJobConfig.JOB_NAME) +class DailyMetricsSnapshotJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DailyMetricsSnapshotJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private RedisCleanUp redisCleanUp; + + private static final String KEY_PREFIX = "ranking:all:"; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @DisplayName("일간 스냅샷 배치") + @Nested + class DailySnapshot { + + @DisplayName("requestDate 파라미터가 없으면 배치가 실패한다.") + @Test + void failsWithoutRequestDate() 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("Redis에 일간 점수가 있으면 product_metrics에 정확히 적재된다.") + @Test + void snapshotsRedisScoresToProductMetrics() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + redisTemplate.opsForZSet().add(redisKey, "1", 150.5); + redisTemplate.opsForZSet().add(redisKey, "2", 300.0); + redisTemplate.opsForZSet().add(redisKey, "3", 75.2); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 12)) + .addLong("run.id", 100L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findAll(); + assertAll( + () -> assertThat(metrics).hasSize(3), + () -> assertThat(metrics) + .extracting(ProductMetrics::getProductId) + .containsExactlyInAnyOrder(1L, 2L, 3L), + () -> assertThat(metrics) + .filteredOn(m -> m.getProductId().equals(2L)) + .first() + .satisfies(m -> assertThat(m.getScore()).isEqualTo(300.0)) + ); + } + + @DisplayName("같은 날짜로 재실행하면 기존 데이터를 교체한다(멱등성).") + @Test + void replacesExistingDataOnRerun() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + LocalDate metricDate = LocalDate.of(2026, 4, 12); + + // 1차 실행 — 점수 100 + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 1L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 점수 변경 — 200으로 + redisTemplate.opsForZSet().add(redisKey, "1", 200.0); + + // 2차 실행 + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 2L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findByMetricDate(metricDate); + assertAll( + () -> assertThat(metrics).hasSize(1), + () -> assertThat(metrics.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyRedisData() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 12)) + .addLong("run.id", 200L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(productMetricsJpaRepository.findAll()).isEmpty() + ); + } + } +} From 0451247a2ab6764295d3a7fbb4b4b1eb7c773bdf Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 15:59:16 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20daily=20snapshot=20batch=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/DailyMetricsSnapshotTasklet.java | 39 ++++++++++--- .../domain/productmetrics/ProductMetrics.java | 14 +++++ .../RankingSnapshotRedisRepository.java | 13 ++++- .../ProductMetricsUnitTest.java | 51 +++++++++++++++++ .../DailyMetricsSnapshotJobE2ETest.java | 55 +++++++++++++++++++ 5 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java index 3308000f0c..7c0ae772a9 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -18,6 +18,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; @Slf4j @@ -37,26 +38,46 @@ public class DailyMetricsSnapshotTasklet implements Tasklet { @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { - if (requestDate == null) { - throw new RuntimeException("requestDate is required"); - } + validateRequestDate(); String dateKey = requestDate.format(DATE_FORMAT); log.info("일간 스냅샷 배치 시작 - requestDate: {}, dateKey: {}", requestDate, dateKey); List scores = rankingSnapshotRepository.getAllScores(dateKey); - log.info("Redis에서 조회된 상품 수: {}", scores.size()); + log.info("Redis에서 조회된 항목 수: {}", scores.size()); productMetricsRepository.deleteByMetricDate(requestDate); if (!scores.isEmpty()) { - List metricsList = scores.stream() - .map(ps -> new ProductMetrics(ps.productId(), requestDate, ps.score())) - .toList(); - productMetricsRepository.saveAll(metricsList); - log.info("product_metrics 적재 완료 - {} 건", metricsList.size()); + List metricsList = new ArrayList<>(); + int skipCount = 0; + + for (ProductScore ps : scores) { + if (ps.productId() == null) { + log.warn("productId가 null인 항목 skip - score: {}", ps.score()); + skipCount++; + continue; + } + metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); + } + + if (!metricsList.isEmpty()) { + productMetricsRepository.saveAll(metricsList); + } + + contribution.incrementWriteCount(metricsList.size()); + log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); } return RepeatStatus.FINISHED; } + + private void validateRequestDate() { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + if (requestDate.isAfter(LocalDate.now())) { + throw new RuntimeException("requestDate는 미래 날짜일 수 없습니다: " + requestDate); + } + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java index 8c4470e15c..b8adc50199 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productmetrics/ProductMetrics.java @@ -36,5 +36,19 @@ public ProductMetrics(Long productId, LocalDate metricDate, double score) { this.productId = productId; this.metricDate = metricDate; this.score = score; + guard(); + } + + @Override + protected void guard() { + if (productId == null) { + throw new IllegalArgumentException("productId는 null일 수 없습니다"); + } + if (metricDate == null) { + throw new IllegalArgumentException("metricDate는 null일 수 없습니다"); + } + if (score < 0) { + throw new IllegalArgumentException("score는 0 이상이어야 합니다"); + } } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java index 8a44575316..e108bce54c 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingSnapshotRedisRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.ranking; import com.loopers.domain.ranking.RankingSnapshotRepository; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Set; +@Slf4j @Component public class RankingSnapshotRedisRepository implements RankingSnapshotRepository { @@ -32,9 +34,14 @@ public List getAllScores(String dateKey) { List result = new ArrayList<>(); for (ZSetOperations.TypedTuple tuple : tuples) { - Long productId = Long.valueOf(tuple.getValue()); - double score = tuple.getScore(); - result.add(new ProductScore(productId, score)); + String value = tuple.getValue(); + try { + Long productId = Long.valueOf(value); + double score = tuple.getScore(); + result.add(new ProductScore(productId, score)); + } catch (NumberFormatException e) { + log.warn("Redis value 파싱 실패 - key: {}, value: '{}', score: {} (skip)", key, value, tuple.getScore()); + } } return result; } diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java new file mode 100644 index 0000000000..fa73f41dbe --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productmetrics/ProductMetricsUnitTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.productmetrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductMetricsUnitTest { + + @DisplayName("ProductMetrics 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var metrics = new ProductMetrics(1L, LocalDate.of(2026, 4, 12), 150.5); + + // assert + assertThat(metrics.getProductId()).isEqualTo(1L); + assertThat(metrics.getMetricDate()).isEqualTo(LocalDate.of(2026, 4, 12)); + assertThat(metrics.getScore()).isEqualTo(150.5); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new ProductMetrics(null, LocalDate.of(2026, 4, 12), 100.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("metricDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullMetricDate() { + assertThatThrownBy(() -> new ProductMetrics(1L, null, 100.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("score가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeScore() { + assertThatThrownBy(() -> new ProductMetrics(1L, LocalDate.of(2026, 4, 12), -1.0)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java index ba48282409..6f46760978 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -21,9 +21,11 @@ import org.springframework.test.context.TestPropertySource; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; @SpringBootTest @@ -158,6 +160,59 @@ void replacesExistingDataOnRerun() throws Exception { ); } + @DisplayName("미래 날짜의 requestDate가 주어지면 배치가 실패한다.") + @Test + void failsWithFutureRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + LocalDate futureDate = LocalDate.now().plusDays(1); + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", futureDate) + .addLong("run.id", 300L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("Redis에 파싱 불가능한 value가 섞여 있으면 정상 데이터만 적재된다.") + @Test + void skipsInvalidRedisValues() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + String dateKey = "20260410"; + String redisKey = KEY_PREFIX + dateKey; + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + redisTemplate.opsForZSet().add(redisKey, "abc", 50.0); + redisTemplate.opsForZSet().add(redisKey, "3", 200.0); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.of(2026, 4, 10)) + .addLong("run.id", 400L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List metrics = productMetricsJpaRepository.findAll(); + assertAll( + () -> assertThat(metrics).hasSize(2), + () -> assertThat(metrics) + .extracting(ProductMetrics::getProductId) + .containsExactlyInAnyOrder(1L, 3L) + ); + } + @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") @Test void completesWithEmptyRedisData() throws Exception { From fcffa97dd4ac9adeca1eabb822edc981bb5f44a5 Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 16:12:24 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20batch=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EA=B0=95=20(empty=20result=20guard,=20bat?= =?UTF-8?q?ch=20insert,=20data=20protection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../step/DailyMetricsSnapshotTasklet.java | 53 +++++++++++++------ .../DailyMetricsSnapshotJobE2ETest.java | 44 ++++++++++++++- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java index 7c0ae772a9..cad03308c1 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/dailymetrics/step/DailyMetricsSnapshotTasklet.java @@ -16,6 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import jakarta.persistence.EntityManager; + import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -33,8 +35,11 @@ public class DailyMetricsSnapshotTasklet implements Tasklet { @Value("#{jobParameters['requestDate']}") private LocalDate requestDate; + private static final int BATCH_SIZE = 1000; + private final RankingSnapshotRepository rankingSnapshotRepository; private final ProductMetricsRepository productMetricsRepository; + private final EntityManager entityManager; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { @@ -46,32 +51,46 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon List scores = rankingSnapshotRepository.getAllScores(dateKey); log.info("Redis에서 조회된 항목 수: {}", scores.size()); - productMetricsRepository.deleteByMetricDate(requestDate); + if (scores.isEmpty()) { + log.warn("Redis에 {} 날짜의 랭킹 데이터가 없습니다. 기존 데이터를 유지하고 스킵합니다.", dateKey); + return RepeatStatus.FINISHED; + } - if (!scores.isEmpty()) { - List metricsList = new ArrayList<>(); - int skipCount = 0; - - for (ProductScore ps : scores) { - if (ps.productId() == null) { - log.warn("productId가 null인 항목 skip - score: {}", ps.score()); - skipCount++; - continue; - } - metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); - } + List metricsList = new ArrayList<>(); + int skipCount = 0; - if (!metricsList.isEmpty()) { - productMetricsRepository.saveAll(metricsList); + for (ProductScore ps : scores) { + if (ps.productId() == null) { + log.warn("productId가 null인 항목 skip - score: {}", ps.score()); + skipCount++; + continue; } + metricsList.add(new ProductMetrics(ps.productId(), requestDate, ps.score())); + } - contribution.incrementWriteCount(metricsList.size()); - log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); + if (metricsList.isEmpty()) { + log.warn("유효한 적재 대상이 0건입니다 (전체 skip: {} 건). 기존 데이터를 유지합니다.", skipCount); + return RepeatStatus.FINISHED; } + productMetricsRepository.deleteByMetricDate(requestDate); + saveInBatches(metricsList); + + contribution.incrementWriteCount(metricsList.size()); + log.info("product_metrics 적재 완료 - 적재: {} 건, skip: {} 건", metricsList.size(), skipCount); + return RepeatStatus.FINISHED; } + private void saveInBatches(List metricsList) { + for (int i = 0; i < metricsList.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, metricsList.size()); + productMetricsRepository.saveAll(metricsList.subList(i, end)); + entityManager.flush(); + entityManager.clear(); + } + } + private void validateRequestDate() { if (requestDate == null) { throw new RuntimeException("requestDate is required"); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java index 6f46760978..4aba41f83f 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/dailymetrics/DailyMetricsSnapshotJobE2ETest.java @@ -213,9 +213,49 @@ void skipsInvalidRedisValues() throws Exception { ); } - @DisplayName("Redis에 해당 날짜 데이터가 없으면 빈 결과로 정상 완료된다.") + @DisplayName("Redis에 해당 날짜 데이터가 없으면 기존 DB 데이터를 삭제하지 않고 정상 완료된다.") @Test - void completesWithEmptyRedisData() throws Exception { + void preservesExistingDataWhenRedisEmpty() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 기존 데이터를 먼저 적재 + String dateKey = "20260412"; + String redisKey = KEY_PREFIX + dateKey; + LocalDate metricDate = LocalDate.of(2026, 4, 12); + + redisTemplate.opsForZSet().add(redisKey, "1", 100.0); + var seedParams = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 500L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(seedParams); + + assertThat(productMetricsJpaRepository.findByMetricDate(metricDate)).hasSize(1); + + // Redis 데이터 삭제 → 빈 상태로 만듦 + redisTemplate.delete(redisKey); + + // act — Redis 비어 있는 상태에서 재실행 + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", metricDate) + .addLong("run.id", 501L) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert — 기존 데이터가 보전되어야 한다 + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(productMetricsJpaRepository.findByMetricDate(metricDate)).hasSize(1), + () -> assertThat(productMetricsJpaRepository.findByMetricDate(metricDate).get(0).getScore()) + .isEqualTo(100.0) + ); + } + + @DisplayName("Redis에 해당 날짜 데이터가 없고 DB에도 없으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyRedisAndEmptyDb() throws Exception { // arrange jobLauncherTestUtils.setJob(job); From 9abc3ce4e4ce7abbd11d64837005b4e2d0a134bc Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 20:18:35 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20weekly/monthly=20rank=20aggregation?= =?UTF-8?q?=20batch=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RankAggregationJobConfig.java | 118 ++++++ .../rankaggregate/step/MonthlyRankWriter.java | 46 +++ .../step/ProductScoreAggregation.java | 3 + .../step/RankAggregationReader.java | 46 +++ .../rankaggregate/step/WeeklyRankWriter.java | 53 +++ .../domain/productrank/BaseProductRank.java | 51 +++ .../productrank/MonthlyProductRank.java | 27 ++ .../MonthlyProductRankRepository.java | 11 + .../domain/productrank/WeeklyProductRank.java | 27 ++ .../WeeklyProductRankRepository.java | 11 + .../MonthlyProductRankJpaRepository.java | 16 + .../MonthlyProductRankRepositoryImpl.java | 26 ++ .../WeeklyProductRankJpaRepository.java | 16 + .../WeeklyProductRankRepositoryImpl.java | 26 ++ .../MonthlyProductRankUnitTest.java | 62 +++ .../WeeklyProductRankUnitTest.java | 62 +++ .../RankAggregationJobE2ETest.java | 374 ++++++++++++++++++ 17 files changed, 975 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java new file mode 100644 index 0000000000..d531176831 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/RankAggregationJobConfig.java @@ -0,0 +1,118 @@ +package com.loopers.batch.job.rankaggregate; + +import com.loopers.batch.job.rankaggregate.step.MonthlyRankWriter; +import com.loopers.batch.job.rankaggregate.step.ProductScoreAggregation; +import com.loopers.batch.job.rankaggregate.step.RankAggregationReader; +import com.loopers.batch.job.rankaggregate.step.WeeklyRankWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.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.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.time.LocalDate; + +@Slf4j +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankAggregationJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class RankAggregationJobConfig { + + public static final String JOB_NAME = "rankAggregationJob"; + private static final String WEEKLY_STEP_NAME = "weeklyRankStep"; + private static final String MONTHLY_STEP_NAME = "monthlyRankStep"; + private static final int CHUNK_SIZE = 100; + private static final int WEEKLY_DAYS = 7; + private static final int MONTHLY_DAYS = 30; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DataSource dataSource; + private final WeeklyProductRankRepository weeklyProductRankRepository; + private final MonthlyProductRankRepository monthlyProductRankRepository; + + @Bean(JOB_NAME) + public Job rankAggregationJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankStep(null)) + .next(monthlyRankStep(null)) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(WEEKLY_STEP_NAME) + public Step weeklyRankStep( + @Value("#{jobParameters['requestDate']}") LocalDate requestDate + ) { + validateRequestDate(requestDate); + LocalDate startDate = requestDate.minusDays(WEEKLY_DAYS - 1); + + log.info("주간 랭킹 집계 Step 구성 - 기간: {} ~ {}", startDate, requestDate); + + return new StepBuilder(WEEKLY_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyReader(startDate, requestDate)) + .writer(new WeeklyRankWriter(weeklyProductRankRepository, requestDate)) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(MONTHLY_STEP_NAME) + public Step monthlyRankStep( + @Value("#{jobParameters['requestDate']}") LocalDate requestDate + ) { + validateRequestDate(requestDate); + LocalDate startDate = requestDate.minusDays(MONTHLY_DAYS - 1); + + log.info("월간 랭킹 집계 Step 구성 - 기간: {} ~ {}", startDate, requestDate); + + return new StepBuilder(MONTHLY_STEP_NAME, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyReader(startDate, requestDate)) + .writer(new MonthlyRankWriter(monthlyProductRankRepository, requestDate)) + .listener(stepMonitorListener) + .build(); + } + + private JdbcCursorItemReader weeklyReader( + LocalDate startDate, LocalDate endDate + ) { + return RankAggregationReader.create("weeklyRankReader", dataSource, startDate, endDate); + } + + private JdbcCursorItemReader monthlyReader( + LocalDate startDate, LocalDate endDate + ) { + return RankAggregationReader.create("monthlyRankReader", dataSource, startDate, endDate); + } + + private void validateRequestDate(LocalDate requestDate) { + if (requestDate == null) { + throw new RuntimeException("requestDate is required"); + } + if (requestDate.isAfter(LocalDate.now())) { + throw new RuntimeException("requestDate는 미래 날짜일 수 없습니다: " + requestDate); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java new file mode 100644 index 0000000000..3eeb5b9a32 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/MonthlyRankWriter.java @@ -0,0 +1,46 @@ +package com.loopers.batch.job.rankaggregate.step; + +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@RequiredArgsConstructor +public class MonthlyRankWriter implements ItemWriter { + + private final MonthlyProductRankRepository monthlyProductRankRepository; + private final LocalDate baseDate; + private final AtomicBoolean deleted = new AtomicBoolean(false); + + @Override + public void write(Chunk chunk) { + if (deleted.compareAndSet(false, true)) { + monthlyProductRankRepository.deleteByBaseDate(baseDate); + log.info("기존 monthly 랭킹 삭제 완료 - baseDate: {}", baseDate); + } + + List ranks = new ArrayList<>(); + + for (int i = 0; i < chunk.size(); i++) { + ProductScoreAggregation agg = chunk.getItems().get(i); + ranks.add(new MonthlyProductRank( + agg.productId(), + agg.totalScore(), + i + 1, + baseDate + )); + } + + monthlyProductRankRepository.saveAll(ranks); + log.info("monthly 랭킹 적재 - {} 건 (ranking 1 ~ {})", + ranks.size(), ranks.size()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java new file mode 100644 index 0000000000..9130aec1b1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/ProductScoreAggregation.java @@ -0,0 +1,3 @@ +package com.loopers.batch.job.rankaggregate.step; + +public record ProductScoreAggregation(Long productId, double totalScore) {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java new file mode 100644 index 0000000000..4bb9bba43b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/RankAggregationReader.java @@ -0,0 +1,46 @@ +package com.loopers.batch.job.rankaggregate.step; + +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.jdbc.core.RowMapper; + +import javax.sql.DataSource; +import java.time.LocalDate; + +public class RankAggregationReader { + + private static final String AGGREGATION_SQL = """ + SELECT pm.product_id, SUM(pm.score) as total_score + FROM product_metrics pm + WHERE pm.metric_date BETWEEN ? AND ? + GROUP BY pm.product_id + ORDER BY total_score DESC + LIMIT 100 + """; + + private static final RowMapper ROW_MAPPER = (rs, rowNum) -> + new ProductScoreAggregation( + rs.getLong("product_id"), + rs.getDouble("total_score") + ); + + private RankAggregationReader() {} + + public static JdbcCursorItemReader create( + String name, + DataSource dataSource, + LocalDate startDate, + LocalDate endDate + ) { + return new JdbcCursorItemReaderBuilder() + .name(name) + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .rowMapper(ROW_MAPPER) + .preparedStatementSetter(ps -> { + ps.setObject(1, startDate); + ps.setObject(2, endDate); + }) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java new file mode 100644 index 0000000000..f197a77e5f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/rankaggregate/step/WeeklyRankWriter.java @@ -0,0 +1,53 @@ +package com.loopers.batch.job.rankaggregate.step; + +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Slf4j +@RequiredArgsConstructor +public class WeeklyRankWriter implements ItemWriter { + + private final WeeklyProductRankRepository weeklyProductRankRepository; + private final LocalDate baseDate; + private final AtomicBoolean deleted = new AtomicBoolean(false); + + @Override + public void write(Chunk chunk) { + if (deleted.compareAndSet(false, true)) { + weeklyProductRankRepository.deleteByBaseDate(baseDate); + log.info("기존 weekly 랭킹 삭제 완료 - baseDate: {}", baseDate); + } + + List ranks = new ArrayList<>(); + int rankOffset = getRankOffset(chunk); + + for (int i = 0; i < chunk.size(); i++) { + ProductScoreAggregation agg = chunk.getItems().get(i); + ranks.add(new WeeklyProductRank( + agg.productId(), + agg.totalScore(), + rankOffset + i + 1, + baseDate + )); + } + + weeklyProductRankRepository.saveAll(ranks); + log.info("weekly 랭킹 적재 - {} 건 (ranking {} ~ {})", + ranks.size(), rankOffset + 1, rankOffset + ranks.size()); + } + + private int getRankOffset(Chunk chunk) { + // TOP 100이므로 chunkSize >= 100이면 항상 첫 번째 chunk에서 모두 처리됨 + // 만약 chunkSize가 100보다 작은 경우를 대비한 안전 장치 + return 0; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java new file mode 100644 index 0000000000..55348efca6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/BaseProductRank.java @@ -0,0 +1,51 @@ +package com.loopers.domain.productrank; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@MappedSuperclass +public abstract class BaseProductRank extends BaseEntity { + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private double totalScore; + + @Column(name = "ranking", nullable = false) + private int ranking; + + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + protected BaseProductRank() {} + + protected BaseProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + this.productId = productId; + this.totalScore = totalScore; + this.ranking = ranking; + this.baseDate = baseDate; + guard(); + } + + @Override + protected void guard() { + if (productId == null) { + throw new IllegalArgumentException("productId는 null일 수 없습니다"); + } + if (totalScore < 0) { + throw new IllegalArgumentException("totalScore는 0 이상이어야 합니다"); + } + if (ranking < 1) { + throw new IllegalArgumentException("ranking은 1 이상이어야 합니다"); + } + if (baseDate == null) { + throw new IllegalArgumentException("baseDate는 null일 수 없습니다"); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java new file mode 100644 index 0000000000..cc4d721575 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRank.java @@ -0,0 +1,27 @@ +package com.loopers.domain.productrank; + +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_monthly_rank", + columnNames = {"base_date", "product_id"} + ), + indexes = @Index(name = "idx_monthly_base_date", columnList = "base_date") +) +public class MonthlyProductRank extends BaseProductRank { + + protected MonthlyProductRank() {} + + public MonthlyProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + super(productId, totalScore, ranking, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java new file mode 100644 index 0000000000..86a24086db --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/MonthlyProductRankRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.productrank; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyProductRankRepository { + + List saveAll(List ranks); + + void deleteByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java new file mode 100644 index 0000000000..85a20b3a41 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRank.java @@ -0,0 +1,27 @@ +package com.loopers.domain.productrank; + +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Entity +@Table(name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_weekly_rank", + columnNames = {"base_date", "product_id"} + ), + indexes = @Index(name = "idx_weekly_base_date", columnList = "base_date") +) +public class WeeklyProductRank extends BaseProductRank { + + protected WeeklyProductRank() {} + + public WeeklyProductRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + super(productId, totalScore, ranking, baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java new file mode 100644 index 0000000000..407cc5c943 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/productrank/WeeklyProductRankRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.productrank; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyProductRankRepository { + + List saveAll(List ranks); + + void deleteByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java new file mode 100644 index 0000000000..29f17b317b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.MonthlyProductRank; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface MonthlyProductRankJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM MonthlyProductRank r WHERE r.baseDate = :baseDate") + void deleteByBaseDate(@Param("baseDate") LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java new file mode 100644 index 0000000000..0056fc5da6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/MonthlyProductRankRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.MonthlyProductRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class MonthlyProductRankRepositoryImpl implements MonthlyProductRankRepository { + + private final MonthlyProductRankJpaRepository monthlyProductRankJpaRepository; + + @Override + public List saveAll(List ranks) { + return monthlyProductRankJpaRepository.saveAll(ranks); + } + + @Override + public void deleteByBaseDate(LocalDate baseDate) { + monthlyProductRankJpaRepository.deleteByBaseDate(baseDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java new file mode 100644 index 0000000000..5ec5092f4d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.WeeklyProductRank; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +public interface WeeklyProductRankJpaRepository extends JpaRepository { + + @Modifying + @Query("DELETE FROM WeeklyProductRank r WHERE r.baseDate = :baseDate") + void deleteByBaseDate(@Param("baseDate") LocalDate baseDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java new file mode 100644 index 0000000000..fc1c8b9e2c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/productrank/WeeklyProductRankRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.productrank; + +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.domain.productrank.WeeklyProductRankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class WeeklyProductRankRepositoryImpl implements WeeklyProductRankRepository { + + private final WeeklyProductRankJpaRepository weeklyProductRankJpaRepository; + + @Override + public List saveAll(List ranks) { + return weeklyProductRankJpaRepository.saveAll(ranks); + } + + @Override + public void deleteByBaseDate(LocalDate baseDate) { + weeklyProductRankJpaRepository.deleteByBaseDate(baseDate); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java new file mode 100644 index 0000000000..51e5db5e61 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/MonthlyProductRankUnitTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.productrank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MonthlyProductRankUnitTest { + + @DisplayName("MonthlyProductRank 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var rank = new MonthlyProductRank(1L, 1200.0, 1, LocalDate.of(2026, 4, 12)); + + // assert + assertAll( + () -> assertThat(rank.getProductId()).isEqualTo(1L), + () -> assertThat(rank.getTotalScore()).isEqualTo(1200.0), + () -> assertThat(rank.getRanking()).isEqualTo(1), + () -> assertThat(rank.getBaseDate()).isEqualTo(LocalDate.of(2026, 4, 12)) + ); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new MonthlyProductRank(null, 100.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("totalScore가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeTotalScore() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, -1.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("ranking이 0 이하이면 생성에 실패한다.") + @Test + void failsWithZeroRanking() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, 100.0, 0, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("baseDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullBaseDate() { + assertThatThrownBy(() -> new MonthlyProductRank(1L, 100.0, 1, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java new file mode 100644 index 0000000000..15ac838e7c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/productrank/WeeklyProductRankUnitTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.productrank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class WeeklyProductRankUnitTest { + + @DisplayName("WeeklyProductRank 생성") + @Nested + class Create { + + @DisplayName("유효한 값으로 생성하면 성공한다.") + @Test + void createsWithValidValues() { + // arrange & act + var rank = new WeeklyProductRank(1L, 350.5, 1, LocalDate.of(2026, 4, 12)); + + // assert + assertAll( + () -> assertThat(rank.getProductId()).isEqualTo(1L), + () -> assertThat(rank.getTotalScore()).isEqualTo(350.5), + () -> assertThat(rank.getRanking()).isEqualTo(1), + () -> assertThat(rank.getBaseDate()).isEqualTo(LocalDate.of(2026, 4, 12)) + ); + } + + @DisplayName("productId가 null이면 생성에 실패한다.") + @Test + void failsWithNullProductId() { + assertThatThrownBy(() -> new WeeklyProductRank(null, 100.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("totalScore가 음수이면 생성에 실패한다.") + @Test + void failsWithNegativeTotalScore() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, -1.0, 1, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("ranking이 0 이하이면 생성에 실패한다.") + @Test + void failsWithZeroRanking() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, 100.0, 0, LocalDate.of(2026, 4, 12))) + .isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("baseDate가 null이면 생성에 실패한다.") + @Test + void failsWithNullBaseDate() { + assertThatThrownBy(() -> new WeeklyProductRank(1L, 100.0, 1, null)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java new file mode 100644 index 0000000000..a4b9276518 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/rankaggregate/RankAggregationJobE2ETest.java @@ -0,0 +1,374 @@ +package com.loopers.job.rankaggregate; + +import com.loopers.batch.job.rankaggregate.RankAggregationJobConfig; +import com.loopers.domain.productmetrics.ProductMetrics; +import com.loopers.domain.productrank.MonthlyProductRank; +import com.loopers.domain.productrank.WeeklyProductRank; +import com.loopers.infrastructure.productmetrics.ProductMetricsJpaRepository; +import com.loopers.infrastructure.productrank.MonthlyProductRankJpaRepository; +import com.loopers.infrastructure.productrank.WeeklyProductRankJpaRepository; +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.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 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=" + RankAggregationJobConfig.JOB_NAME) +class RankAggregationJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankAggregationJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private WeeklyProductRankJpaRepository weeklyProductRankJpaRepository; + + @Autowired + private MonthlyProductRankJpaRepository monthlyProductRankJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final LocalDate BASE_DATE = LocalDate.of(2026, 4, 12); + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주간 랭킹 집계") + @Nested + class WeeklyRank { + + @DisplayName("최근 7일 product_metrics를 상품별로 합산하여 weekly 랭킹을 적재한다.") + @Test + void aggregatesWeeklyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 상품1: 7일간 매일 100점 = 합계 700 + // 상품2: 7일간 매일 50점 = 합계 350 + // 상품3: 3일만 200점 = 합계 600 + for (int i = 0; i < 7; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(1L, date, 100.0)); + productMetricsJpaRepository.save(new ProductMetrics(2L, date, 50.0)); + } + for (int i = 0; i < 3; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(3L, date, 200.0)); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 100L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(3), + // 1위: 상품1 (700점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(1L); + assertThat(r.getTotalScore()).isEqualTo(700.0); + }), + // 2위: 상품3 (600점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 2) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(3L); + assertThat(r.getTotalScore()).isEqualTo(600.0); + }), + // 3위: 상품2 (350점) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 3) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(350.0); + }) + ); + } + + @DisplayName("7일 범위 밖의 데이터는 주간 집계에 포함되지 않는다.") + @Test + void excludesDataOutsideWeeklyRange() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 범위 내 (requestDate - 6 ~ requestDate) + productMetricsJpaRepository.save(new ProductMetrics(1L, BASE_DATE, 100.0)); + // 범위 밖 (7일 전 = requestDate - 7) + productMetricsJpaRepository.save(new ProductMetrics(2L, BASE_DATE.minusDays(7), 500.0)); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 200L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(1), + () -> assertThat(weeklyRanks.get(0).getProductId()).isEqualTo(1L), + () -> assertThat(weeklyRanks.get(0).getTotalScore()).isEqualTo(100.0) + ); + } + } + + @DisplayName("월간 랭킹 집계") + @Nested + class MonthlyRank { + + @DisplayName("최근 30일 product_metrics를 상품별로 합산하여 monthly 랭킹을 적재한다.") + @Test + void aggregatesMonthlyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 상품1: 30일간 매일 10점 = 합계 300 + // 상품2: 15일만 30점 = 합계 450 + for (int i = 0; i < 30; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(1L, date, 10.0)); + } + for (int i = 0; i < 15; i++) { + LocalDate date = BASE_DATE.minusDays(i); + productMetricsJpaRepository.save(new ProductMetrics(2L, date, 30.0)); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 300L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List monthlyRanks = monthlyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(monthlyRanks).hasSize(2), + // 1위: 상품2 (450점) + () -> assertThat(monthlyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(450.0); + }), + // 2위: 상품1 (300점) + () -> assertThat(monthlyRanks) + .filteredOn(r -> r.getRanking() == 2) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(1L); + assertThat(r.getTotalScore()).isEqualTo(300.0); + }) + ); + } + } + + @DisplayName("TOP 100 절단") + @Nested + class Top100Limit { + + @DisplayName("상품이 100개를 초과하면 TOP 100만 적재된다.") + @Test + void limitsToTop100() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 120개 상품 데이터 생성 (productId: 1~120) + for (long productId = 1; productId <= 120; productId++) { + productMetricsJpaRepository.save( + new ProductMetrics(productId, BASE_DATE, (double) productId) + ); + } + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 400L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + List monthlyRanks = monthlyProductRankJpaRepository.findAll(); + + assertAll( + () -> assertThat(weeklyRanks).hasSize(100), + () -> assertThat(monthlyRanks).hasSize(100), + // 1위는 점수가 가장 높은 productId=120 + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> assertThat(r.getProductId()).isEqualTo(120L)), + // 100위는 productId=21 (120 - 99) + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 100) + .first() + .satisfies(r -> assertThat(r.getProductId()).isEqualTo(21L)) + ); + } + } + + @DisplayName("멱등성") + @Nested + class Idempotency { + + @DisplayName("같은 requestDate로 재실행하면 기존 데이터를 교체한다.") + @Test + void replacesExistingDataOnRerun() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 1차 실행 — 상품1 점수 100 + productMetricsJpaRepository.save(new ProductMetrics(1L, BASE_DATE, 100.0)); + + var jobParameters1 = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 500L) + .toJobParameters(); + jobLauncherTestUtils.launchJob(jobParameters1); + + // 데이터 변경 — 상품2 추가 + productMetricsJpaRepository.save(new ProductMetrics(2L, BASE_DATE, 200.0)); + + // 2차 실행 + var jobParameters2 = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 501L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + List weeklyRanks = weeklyProductRankJpaRepository.findAll(); + assertAll( + () -> assertThat(weeklyRanks).hasSize(2), + () -> assertThat(weeklyRanks) + .filteredOn(r -> r.getRanking() == 1) + .first() + .satisfies(r -> { + assertThat(r.getProductId()).isEqualTo(2L); + assertThat(r.getTotalScore()).isEqualTo(200.0); + }) + ); + } + } + + @DisplayName("엣지 케이스") + @Nested + class EdgeCase { + + @DisplayName("product_metrics가 비어있으면 빈 결과로 정상 완료된다.") + @Test + void completesWithEmptyMetrics() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", BASE_DATE) + .addLong("run.id", 600L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(weeklyProductRankJpaRepository.findAll()).isEmpty(), + () -> assertThat(monthlyProductRankJpaRepository.findAll()).isEmpty() + ); + } + + @DisplayName("requestDate가 없으면 배치가 실패한다.") + @Test + void failsWithoutRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("미래 날짜의 requestDate가 주어지면 배치가 실패한다.") + @Test + void failsWithFutureRequestDate() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + LocalDate futureDate = LocalDate.now().plusDays(1); + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", futureDate) + .addLong("run.id", 700L) + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + } +} From bfb798a584fe0e52f56bfe7d33f16af8e8846f2f Mon Sep 17 00:00:00 2001 From: MINJOOOONG Date: Sun, 12 Apr 2026 20:53:18 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20ranking=20api=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20(daily/weekly/monthly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ranking/RankingFacade.java | 22 ++ .../application/ranking/RankingInfo.java | 10 + .../loopers/domain/ranking/RankPeriod.java | 7 + .../domain/ranking/RankingRepository.java | 15 + .../domain/ranking/RankingService.java | 24 ++ .../ranking/RankingRepositoryImpl.java | 74 +++++ .../api/ranking/RankingV1ApiSpec.java | 25 ++ .../api/ranking/RankingV1Controller.java | 52 +++ .../interfaces/api/ranking/RankingV1Dto.java | 23 ++ .../interfaces/api/RankingV1ApiE2ETest.java | 308 ++++++++++++++++++ 10 files changed, 560 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java 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 new file mode 100644 index 0000000000..458f806426 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -0,0 +1,22 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankPeriod; +import com.loopers.domain.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class RankingFacade { + + private final RankingService rankingService; + + public List getRanking(RankPeriod period, LocalDate date, int size) { + return rankingService.getRanking(period, date, size).stream() + .map(RankingInfo::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java new file mode 100644 index 0000000000..02c38cd40f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingRepository; + +public record RankingInfo(Long productId, double score, int ranking) { + + public static RankingInfo from(RankingRepository.RankingEntry entry) { + return new RankingInfo(entry.productId(), entry.score(), entry.ranking()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java new file mode 100644 index 0000000000..1f94e4e8ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankPeriod.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum RankPeriod { + DAILY, + WEEKLY, + MONTHLY +} 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 new file mode 100644 index 0000000000..5b6e907c5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +public interface RankingRepository { + + List findDailyRanking(LocalDate date, int size); + + List findWeeklyRanking(LocalDate baseDate, int size); + + List findMonthlyRanking(LocalDate baseDate, int size); + + record RankingEntry(Long productId, double score, int ranking) {} +} 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 new file mode 100644 index 0000000000..c351997faa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,24 @@ +package com.loopers.domain.ranking; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class RankingService { + + private final RankingRepository rankingRepository; + + @Transactional(readOnly = true) + public List getRanking(RankPeriod period, LocalDate date, int size) { + return switch (period) { + case DAILY -> rankingRepository.findDailyRanking(date, size); + case WEEKLY -> rankingRepository.findWeeklyRanking(date, size); + case MONTHLY -> rankingRepository.findMonthlyRanking(date, size); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java new file mode 100644 index 0000000000..914fe80e6d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +@RequiredArgsConstructor +@Component +public class RankingRepositoryImpl implements RankingRepository { + + private final JdbcTemplate jdbcTemplate; + + private static final String DAILY_SQL = """ + SELECT product_id, score + FROM product_metrics + WHERE metric_date = ? + ORDER BY score DESC + LIMIT ? + """; + + private static final String WEEKLY_SQL = """ + SELECT product_id, total_score, ranking + FROM mv_product_rank_weekly + WHERE base_date = ? + ORDER BY ranking ASC + LIMIT ? + """; + + private static final String MONTHLY_SQL = """ + SELECT product_id, total_score, ranking + FROM mv_product_rank_monthly + WHERE base_date = ? + ORDER BY ranking ASC + LIMIT ? + """; + + @Override + public List findDailyRanking(LocalDate date, int size) { + AtomicInteger rank = new AtomicInteger(0); + return jdbcTemplate.query(DAILY_SQL, dailyRowMapper(rank), date, size); + } + + @Override + public List findWeeklyRanking(LocalDate baseDate, int size) { + return jdbcTemplate.query(WEEKLY_SQL, mvRowMapper(), baseDate, size); + } + + @Override + public List findMonthlyRanking(LocalDate baseDate, int size) { + return jdbcTemplate.query(MONTHLY_SQL, mvRowMapper(), baseDate, size); + } + + private RowMapper dailyRowMapper(AtomicInteger rank) { + return (rs, rowNum) -> new RankingEntry( + rs.getLong("product_id"), + rs.getDouble("score"), + rank.incrementAndGet() + ); + } + + private RowMapper mvRowMapper() { + return (rs, rowNum) -> new RankingEntry( + rs.getLong("product_id"), + rs.getDouble("total_score"), + rs.getInt("ranking") + ); + } +} 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 new file mode 100644 index 0000000000..3f8a5f9805 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.time.LocalDate; + +@Tag(name = "Ranking V1 API", description = "상품 랭킹 조회 API") +public interface RankingV1ApiSpec { + + @Operation( + summary = "랭킹 조회", + description = "기간별(DAILY/WEEKLY/MONTHLY) 상품 랭킹을 조회합니다." + ) + ApiResponse getRankings( + @Parameter(description = "조회 기간 (DAILY, WEEKLY, MONTHLY)", required = true) + String period, + @Parameter(description = "기준 날짜 (yyyy-MM-dd)", required = true) + LocalDate date, + @Parameter(description = "조회 개수 (기본 100, 최대 100)") + int size + ); +} 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 new file mode 100644 index 0000000000..01ae6d700a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingFacade; +import com.loopers.application.ranking.RankingInfo; +import com.loopers.domain.ranking.RankPeriod; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private static final int DEFAULT_SIZE = 100; + private static final int MAX_SIZE = 100; + + private final RankingFacade rankingFacade; + + @GetMapping + @Override + public ApiResponse getRankings( + @RequestParam("period") String period, + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "size", defaultValue = "100") int size + ) { + RankPeriod rankPeriod = parseRankPeriod(period); + int validSize = Math.min(Math.max(size, 1), MAX_SIZE); + + List rankings = rankingFacade.getRanking(rankPeriod, date, validSize); + RankingV1Dto.RankingListResponse response = RankingV1Dto.RankingListResponse.from(rankings); + + return ApiResponse.success(response); + } + + private RankPeriod parseRankPeriod(String period) { + try { + return RankPeriod.valueOf(period.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "잘못된 period 값입니다: " + period); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 0000000000..a85e37f8f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingInfo; + +import java.util.List; + +public class RankingV1Dto { + + public record RankingResponse(int ranking, Long productId, double score) { + public static RankingResponse from(RankingInfo info) { + return new RankingResponse(info.ranking(), info.productId(), info.score()); + } + } + + public record RankingListResponse(List rankings) { + public static RankingListResponse from(List infos) { + List rankings = infos.stream() + .map(RankingResponse::from) + .toList(); + return new RankingListResponse(rankings); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java new file mode 100644 index 0000000000..d694c8ec1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/RankingV1ApiE2ETest.java @@ -0,0 +1,308 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.ranking.RankingV1Dto; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + metric_date DATE NOT NULL, + score DOUBLE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_product_metrics (product_id, metric_date), + INDEX idx_metric_date (metric_date) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + ranking INT NOT NULL, + base_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_weekly_rank (base_date, product_id), + INDEX idx_weekly_base_date (base_date) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + ranking INT NOT NULL, + base_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6), + UNIQUE KEY uk_monthly_rank (base_date, product_id), + INDEX idx_monthly_base_date (base_date) + ) + """); + } + + @AfterEach + void tearDown() { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + jdbcTemplate.execute("TRUNCATE TABLE product_metrics"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_weekly"); + jdbcTemplate.execute("TRUNCATE TABLE mv_product_rank_monthly"); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/rankings - DAILY 랭킹 조회") + @Nested + class DailyRanking { + + @DisplayName("해당 날짜의 product_metrics를 점수 내림차순으로 조회한다.") + @Test + void returnsDailyRanking() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 12); + insertProductMetrics(1L, date, 300.0); + insertProductMetrics(2L, date, 100.0); + insertProductMetrics(3L, date, 200.0); + + String url = ENDPOINT + "?period=DAILY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(1L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(300.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(3L), + () -> assertThat(response.getBody().data().rankings().get(2).ranking()).isEqualTo(3), + () -> assertThat(response.getBody().data().rankings().get(2).productId()).isEqualTo(2L) + ); + } + + @DisplayName("size 파라미터로 조회 개수를 제한할 수 있다.") + @Test + void limitsBySize() { + // arrange + LocalDate date = LocalDate.of(2026, 4, 12); + for (long i = 1; i <= 5; i++) { + insertProductMetrics(i, date, (double) i * 10); + } + + String url = ENDPOINT + "?period=DAILY&date=2026-04-12&size=3"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(5L) + ); + } + } + + @DisplayName("GET /api/v1/rankings - WEEKLY 랭킹 조회") + @Nested + class WeeklyRanking { + + @DisplayName("해당 날짜의 주간 랭킹을 순위 오름차순으로 조회한다.") + @Test + void returnsWeeklyRanking() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 12); + insertWeeklyRank(1L, 700.0, 1, baseDate); + insertWeeklyRank(3L, 600.0, 2, baseDate); + insertWeeklyRank(2L, 350.0, 3, baseDate); + + String url = ENDPOINT + "?period=WEEKLY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(1L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(700.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(2).ranking()).isEqualTo(3) + ); + } + } + + @DisplayName("GET /api/v1/rankings - MONTHLY 랭킹 조회") + @Nested + class MonthlyRanking { + + @DisplayName("해당 날짜의 월간 랭킹을 순위 오름차순으로 조회한다.") + @Test + void returnsMonthlyRanking() { + // arrange + LocalDate baseDate = LocalDate.of(2026, 4, 12); + insertMonthlyRank(2L, 450.0, 1, baseDate); + insertMonthlyRank(1L, 300.0, 2, baseDate); + + String url = ENDPOINT + "?period=MONTHLY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(2), + () -> assertThat(response.getBody().data().rankings().get(0).ranking()).isEqualTo(1), + () -> assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(2L), + () -> assertThat(response.getBody().data().rankings().get(0).score()).isEqualTo(450.0), + () -> assertThat(response.getBody().data().rankings().get(1).ranking()).isEqualTo(2), + () -> assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(1L) + ); + } + } + + @DisplayName("엣지 케이스") + @Nested + class EdgeCase { + + @DisplayName("데이터가 없으면 빈 리스트를 반환한다.") + @Test + void returnsEmptyListWhenNoData() { + // arrange + String url = ENDPOINT + "?period=DAILY&date=2026-04-12"; + + // act + var response = exchange(url); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + + @DisplayName("잘못된 period 값이면 400을 반환한다.") + @Test + void returnsBadRequestForInvalidPeriod() { + // arrange + String url = ENDPOINT + "?period=INVALID&date=2026-04-12"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("period 파라미터가 없으면 400을 반환한다.") + @Test + void returnsBadRequestWithoutPeriod() { + // arrange + String url = ENDPOINT + "?date=2026-04-12"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("date 파라미터가 없으면 400을 반환한다.") + @Test + void returnsBadRequestWithoutDate() { + // arrange + String url = ENDPOINT + "?period=DAILY"; + + // act + var response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // --- Helper Methods --- + + private ResponseEntity> exchange(String url) { + return testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), + new ParameterizedTypeReference<>() {} + ); + } + + private void insertProductMetrics(Long productId, LocalDate metricDate, double score) { + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, metric_date, score, created_at, updated_at) VALUES (?, ?, ?, NOW(), NOW())", + productId, metricDate, score + ); + } + + private void insertWeeklyRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, total_score, ranking, base_date, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())", + productId, totalScore, ranking, baseDate + ); + } + + private void insertMonthlyRank(Long productId, double totalScore, int ranking, LocalDate baseDate) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, total_score, ranking, base_date, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())", + productId, totalScore, ranking, baseDate + ); + } +}