From 4201d31e17e1845e66ec9652601245e6fc18aeec Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Mon, 13 Apr 2026 19:06:07 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Spring=20Batch=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84=20=EB=B0=B0=EC=B9=98?= =?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 Co-Authored-By: Claude Opus 4.6 --- .../job/ranking/WeeklyRankingJobConfig.java | 63 +++++++ .../ranking/step/WeeklyRankingTasklet.java | 82 +++++++++ .../domain/metrics/ProductMetrics.java | 43 +++++ .../metrics/ProductMetricsRepository.java | 9 + .../metrics/ProductScoreProjection.java | 4 + .../domain/ranking/ProductRankingWeekly.java | 45 +++++ .../ProductRankingWeeklyRepository.java | 23 +++ .../ProductMetricsJpaRepository.java | 28 +++ .../ProductMetricsRepositoryImpl.java | 24 +++ .../ProductRankingWeeklyJpaRepository.java | 27 +++ .../ProductRankingWeeklyRepositoryImpl.java | 26 +++ .../loopers/CommerceBatchApplicationTest.java | 2 + ...ankingWeeklyRepositoryIntegrationTest.java | 87 ++++++++++ .../job/ranking/WeeklyRankingJobE2ETest.java | 164 ++++++++++++++++++ .../job/ranking/WeeklyRankingTaskletTest.java | 128 ++++++++++++++ 15 files changed, 755 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..4304c14e82 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 배치 Job 설정. + * + *

{@code spring.batch.job.name=weeklyRankingJob}으로 실행하며, + * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 7일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_weekly} 테이블에 + * upsert한다.

+ * + *

실행 예시:

+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ *   --args="--spring.batch.job.name=weeklyRankingJob date=20260413"
+ * 
+ */ +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_NAME = "weeklyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final WeeklyRankingTasklet weeklyRankingTasklet; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(weeklyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step weeklyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(weeklyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java new file mode 100644 index 0000000000..a17095d420 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java @@ -0,0 +1,82 @@ +package com.loopers.batch.job.ranking.step; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 주간 랭킹 집계 Tasklet. + * + *

JobParameters의 {@code date}(yyyyMMdd)를 기준으로 + * 직전 7일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_weekly}에 upsert한다.

+ * + *

스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}

+ * + *

집계 범위 예시 (date=20260413):

+ *
+ * start: 2026-04-06 00:00:00
+ * end:   2026-04-12 23:59:59.999999999
+ * 
+ */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyRankingTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int LIMIT = 100; + + private final ProductMetricsRepository productMetricsRepository; + private final ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @Value("#{jobParameters['date']}") + private String date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (Objects.isNull(date)) { + throw new IllegalArgumentException("JobParameter 'date' is required"); + } + + LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); + LocalDateTime start = baseDate.minusDays(7).atStartOfDay(); + LocalDateTime end = baseDate.minusDays(1).atTime(LocalTime.MAX); + + log.info("주간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); + + List topScores = productMetricsRepository.findTopScores(start, end, LIMIT); + log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size()); + + List rankings = topScores.stream() + .map(p -> ProductRankingWeekly.create(p.productId(), baseDate, p.score())) + .toList(); + + productRankingWeeklyRepository.saveAll(rankings); + log.info("주간 랭킹 집계 완료 — {}개 상품 저장", rankings.size()); + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 0000000000..068bf8b14e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,43 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "product_metrics", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "metric_hour"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDateTime metricHour; + + @Column(nullable = false) + private Long viewCount; + + @Column(nullable = false) + private Long likeCount; + + @Column(nullable = false) + private Long orderCount; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 0000000000..0bb1959a6b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.metrics; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ProductMetricsRepository { + + List findTopScores(LocalDateTime start, LocalDateTime end, int limit); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java new file mode 100644 index 0000000000..e3138be515 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java @@ -0,0 +1,4 @@ +package com.loopers.domain.metrics; + +public record ProductScoreProjection(Long productId, Double score) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000000..a9aacbedf2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingWeekly ranking = new ProductRankingWeekly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java new file mode 100644 index 0000000000..c76a698159 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +/** + * 주간 랭킹 저장소. + * + *

배치가 집계한 주간 랭킹 스코어를 저장한다. + * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).

+ */ +public interface ProductRankingWeeklyRepository { + + /** + * 주간 랭킹 스코어를 일괄 저장한다. + * + *

MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여 + * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고, + * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.

+ * + * @param rankings 저장할 주간 랭킹 목록 + */ + void saveAll(List rankings); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java new file mode 100644 index 0000000000..1b404f59c4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.metrics.persistence; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductScoreProjection; + +public interface ProductMetricsJpaRepository extends JpaRepository { + + @Query("SELECT new com.loopers.domain.metrics.ProductScoreProjection(" + + "m.productId, " + + "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) " + + "FROM ProductMetrics m " + + "WHERE m.metricHour BETWEEN :start AND :end " + + "GROUP BY m.productId " + + "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC") + List findTopScores( + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end, + Pageable pageable + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java new file mode 100644 index 0000000000..29d43135b1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.metrics.persistence; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Override + public List findTopScores(LocalDateTime start, LocalDateTime end, int limit) { + return productMetricsJpaRepository.findTopScores(start, end, PageRequest.of(0, limit)); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java new file mode 100644 index 0000000000..f655e24b68 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.ProductRankingWeekly; + +public interface ProductRankingWeeklyJpaRepository extends JpaRepository { + + @Modifying + @Query( + value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) " + + "VALUES (:productId, :scoreDate, :score) " + + "ON DUPLICATE KEY UPDATE " + + "score = :score", + nativeQuery = true + ) + void upsert( + @Param("productId") Long productId, + @Param("scoreDate") LocalDate scoreDate, + @Param("score") Double score + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java new file mode 100644 index 0000000000..7b36967bdb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductRankingWeeklyRepositoryImpl implements ProductRankingWeeklyRepository { + + private final ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Override + @Transactional + public void saveAll(List rankings) { + rankings.forEach(r -> + productRankingWeeklyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..71a9071861 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java new file mode 100644 index 0000000000..d0bbb51ce8 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java @@ -0,0 +1,87 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class ProductRankingWeeklyRepositoryIntegrationTest { + + @Autowired + private ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @Autowired + private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주간 랭킹을 저장할 때,") + @Nested + class SaveAll { + + @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.") + @Test + void updatesScore_whenDuplicateKey() { + // arrange + LocalDate scoreDate = LocalDate.of(2026, 4, 13); + var firstRanking = ProductRankingWeekly.create(1L, scoreDate, 100.0); + productRankingWeeklyRepository.saveAll(List.of(firstRanking)); + + // act + var updatedRanking = ProductRankingWeekly.create(1L, scoreDate, 200.0); + productRankingWeeklyRepository.saveAll(List.of(updatedRanking)); + + // assert + List results = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("다른 score_date이면, 별도 row로 저장된다.") + @Test + void createsSeparateRows_whenDifferentScoreDate() { + // arrange + LocalDate date1 = LocalDate.of(2026, 4, 13); + LocalDate date2 = LocalDate.of(2026, 4, 14); + + // act + productRankingWeeklyRepository.saveAll( + List.of(ProductRankingWeekly.create(1L, date1, 100.0))); + productRankingWeeklyRepository.saveAll( + List.of(ProductRankingWeekly.create(1L, date2, 150.0))); + + // assert + List results = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(2), + () -> assertThat(results).extracting(ProductRankingWeekly::getScore) + .containsExactlyInAnyOrder(100.0, 150.0) + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..778f6aba64 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,164 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.WeeklyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingWeeklyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private ProductRankingWeeklyJpaRepository productRankingWeeklyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("weeklyRankingJob을 실행할 때,") + @Nested + class RunJob { + + @DisplayName("date 파라미터가 없으면, Job이 실패한다.") + @Test + void failsJob_whenDateParameterIsMissing() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("7일치 product_metrics를 집계하여 주간 랭킹을 저장한다.") + @Test + void aggregatesWeeklyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDateTime.of(2026, 4, 7, 10, 0), 100L, 10L, 5L); + saveMetrics(1L, LocalDateTime.of(2026, 4, 8, 14, 0), 200L, 20L, 10L); + saveMetrics(2L, LocalDateTime.of(2026, 4, 9, 9, 0), 50L, 5L, 3L); + + var jobParameters = new JobParametersBuilder() + .addString("date", "20260413") + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List rankings = productRankingWeeklyJpaRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rankings).hasSize(2), + () -> { + // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5 + ProductRankingWeekly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13)); + }, + () -> { + // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1 + ProductRankingWeekly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).") + @Test + void isIdempotent_whenRerunWithSameParameters() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100L, 10L, 5L); + + var jobParameters1 = new JobParametersBuilder() + .addString("date", "20260413") + .addLong("run.id", 1L) + .toJobParameters(); + var jobParameters2 = new JobParametersBuilder() + .addString("date", "20260413") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(jobParameters1); + jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + List rankings = productRankingWeeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L) + ); + } + } + + private void saveMetrics(Long productId, LocalDateTime metricHour, + Long viewCount, Long likeCount, Long orderCount) { + try { + var constructor = ProductMetrics.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ProductMetrics metrics = constructor.newInstance(); + ReflectionTestUtils.setField(metrics, "productId", productId); + ReflectionTestUtils.setField(metrics, "metricHour", metricHour); + ReflectionTestUtils.setField(metrics, "viewCount", viewCount); + ReflectionTestUtils.setField(metrics, "likeCount", likeCount); + ReflectionTestUtils.setField(metrics, "orderCount", orderCount); + productMetricsJpaRepository.save(metrics); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java new file mode 100644 index 0000000000..00e8e87235 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java @@ -0,0 +1,128 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.step.WeeklyRankingTasklet; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.ProductRankingWeeklyRepository; + +@ExtendWith(MockitoExtension.class) +class WeeklyRankingTaskletTest { + + @InjectMocks + private WeeklyRankingTasklet tasklet; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private ProductRankingWeeklyRepository productRankingWeeklyRepository; + + @DisplayName("주간 랭킹 집계를 수행할 때,") + @Nested + class Execute { + + @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenDateIsNull() { + // arrange + ReflectionTestUtils.setField(tasklet, "date", null); + + // act & assert + assertThatThrownBy(() -> tasklet.execute( + mock(StepContribution.class), mock(ChunkContext.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("date"); + } + + @DisplayName("DB에서 조회한 Top N 스코어를 주간 랭킹으로 저장한다.") + @Test + @SuppressWarnings("unchecked") + void savesTopNScoresAsWeeklyRanking() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260413"); + + List topScores = List.of( + new ProductScoreProjection(1L, 46.5), + new ProductScoreProjection(2L, 8.1) + ); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(topScores); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingWeeklyRepository).saveAll(captor.capture()); + + List rankings = captor.getValue(); + + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> { + ProductRankingWeekly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13)); + }, + () -> { + ProductRankingWeekly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 7일 전 ~ 전날).") + @Test + void calculatesCorrectDateRange() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260413"); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(List.of()); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); + + assertAll( + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 4, 6, 0, 0, 0)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 12).atTime(java.time.LocalTime.MAX)) + ); + } + } +} From 7eb7dd4736a18a23d923b04723a3dbcc06188eae Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Wed, 15 Apr 2026 09:28:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20Spring=20Batch=20=EC=9B=94=EA=B0=84?= =?UTF-8?q?=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84=20=EB=B0=B0=EC=B9=98?= =?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 Co-Authored-By: Claude Opus 4.6 --- .../job/ranking/MonthlyRankingJobConfig.java | 63 +++++++ .../ranking/step/MonthlyRankingTasklet.java | 82 +++++++++ .../domain/ranking/ProductRankingMonthly.java | 45 +++++ .../ProductRankingMonthlyRepository.java | 23 +++ .../ProductRankingMonthlyJpaRepository.java | 27 +++ .../ProductRankingMonthlyRepositoryImpl.java | 26 +++ .../job/ranking/MonthlyRankingJobE2ETest.java | 165 ++++++++++++++++++ .../ranking/MonthlyRankingTaskletTest.java | 128 ++++++++++++++ ...nkingMonthlyRepositoryIntegrationTest.java | 87 +++++++++ 9 files changed, 646 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..3984e6664e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 배치 Job 설정. + * + *

{@code spring.batch.job.name=monthlyRankingJob}으로 실행하며, + * JobParameters로 {@code date}(yyyyMMdd)를 전달받는다. 해당 날짜 기준 직전 30일의 {@code product_metrics}를 집계하여 {@code mv_product_rank_monthly} + * 테이블에 upsert한다.

+ * + *

실행 예시:

+ *
+ * ./gradlew :apps:commerce-batch:bootRun \
+ *   --args="--spring.batch.job.name=monthlyRankingJob date=20260414"
+ * 
+ */ +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_NAME = "monthlyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final MonthlyRankingTasklet monthlyRankingTasklet; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .start(monthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_NAME) + public Step monthlyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(monthlyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java new file mode 100644 index 0000000000..9820b6ee24 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java @@ -0,0 +1,82 @@ +package com.loopers.batch.job.ranking.step; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; + +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 월간 랭킹 집계 Tasklet. + * + *

JobParameters의 {@code date}(yyyyMMdd)를 기준으로 + * 직전 30일(당일 제외)의 {@code product_metrics}를 집계하여 상품별 가중 스코어 상위 100개를 {@code mv_product_rank_monthly}에 upsert한다.

+ * + *

스코어 공식: {@code view_count × 0.1 + like_count × 0.2 + order_count × 0.7}

+ * + *

집계 범위 예시 (date=20260414):

+ *
+ * start: 2026-03-15 00:00:00
+ * end:   2026-04-13 23:59:59.999999999
+ * 
+ */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyRankingTasklet implements Tasklet { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int LIMIT = 100; + + private final ProductMetricsRepository productMetricsRepository; + private final ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @Value("#{jobParameters['date']}") + private String date; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + if (Objects.isNull(date)) { + throw new IllegalArgumentException("JobParameter 'date' is required"); + } + + LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); + LocalDateTime start = baseDate.minusDays(30).atStartOfDay(); + LocalDateTime end = baseDate.minusDays(1).atTime(LocalTime.MAX); + + log.info("월간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); + + List topScores = productMetricsRepository.findTopScores(start, end, LIMIT); + log.info("Top {} 스코어 집계 완료 — {}건", LIMIT, topScores.size()); + + List rankings = topScores.stream() + .map(p -> ProductRankingMonthly.create(p.productId(), baseDate, p.score())) + .toList(); + + productRankingMonthlyRepository.saveAll(rankings); + log.info("월간 랭킹 집계 완료 — {}개 상품 저장", rankings.size()); + + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000000..741c60584d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_monthly", uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingMonthly ranking = new ProductRankingMonthly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java new file mode 100644 index 0000000000..b3690375eb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java @@ -0,0 +1,23 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +/** + * 월간 랭킹 저장소. + * + *

배치가 집계한 월간 랭킹 스코어를 저장한다. + * 동일한 (productId, scoreDate) 조합이 이미 존재하면 score를 덮어쓴다 (upsert).

+ */ +public interface ProductRankingMonthlyRepository { + + /** + * 월간 랭킹 스코어를 일괄 저장한다. + * + *

MySQL의 {@code ON DUPLICATE KEY UPDATE}를 활용하여 + * 동일한 (productId, scoreDate) 조합이 존재하면 score를 갱신하고, + * 존재하지 않으면 새로 생성한다. 이를 통해 멱등성이 보장된다.

+ * + * @param rankings 저장할 월간 랭킹 목록 + */ + void saveAll(List rankings); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java new file mode 100644 index 0000000000..2ab438af21 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.loopers.domain.ranking.ProductRankingMonthly; + +public interface ProductRankingMonthlyJpaRepository extends JpaRepository { + + @Modifying + @Query( + value = "INSERT INTO mv_product_rank_monthly (product_id, score_date, score) " + + "VALUES (:productId, :scoreDate, :score) " + + "ON DUPLICATE KEY UPDATE " + + "score = :score", + nativeQuery = true + ) + void upsert( + @Param("productId") Long productId, + @Param("scoreDate") LocalDate scoreDate, + @Param("score") Double score + ); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java new file mode 100644 index 0000000000..3b2d656a9e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.util.List; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProductRankingMonthlyRepositoryImpl implements ProductRankingMonthlyRepository { + + private final ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Override + @Transactional + public void saveAll(List rankings) { + rankings.forEach(r -> + productRankingMonthlyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore()) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..04baf4c417 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -0,0 +1,165 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.infrastructure.metrics.persistence.ProductMetricsJpaRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("monthlyRankingJob을 실행할 때,") + @Nested + class RunJob { + + @DisplayName("date 파라미터가 없으면, Job이 실패한다.") + @Test + void failsJob_whenDateParameterIsMissing() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("30일치 product_metrics를 집계하여 월간 랭킹을 저장한다.") + @Test + void aggregatesMonthlyRanking() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // 30일 범위 내 데이터 (date=20260414 → 03/15 ~ 04/13) + saveMetrics(1L, LocalDateTime.of(2026, 3, 20, 10, 0), 100L, 10L, 5L); + saveMetrics(1L, LocalDateTime.of(2026, 4, 5, 14, 0), 200L, 20L, 10L); + saveMetrics(2L, LocalDateTime.of(2026, 4, 10, 9, 0), 50L, 5L, 3L); + + var jobParameters = new JobParametersBuilder() + .addString("date", "20260414") + .toJobParameters(); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + List rankings = productRankingMonthlyJpaRepository.findAll(); + + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rankings).hasSize(2), + () -> { + // product 1: (100+200)*0.1 + (10+20)*0.2 + (5+10)*0.7 = 30 + 6 + 10.5 = 46.5 + ProductRankingMonthly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14)); + }, + () -> { + // product 2: 50*0.1 + 5*0.2 + 3*0.7 = 5 + 1 + 2.1 = 8.1 + ProductRankingMonthly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("같은 파라미터로 재실행해도 결과가 동일하다 (멱등성).") + @Test + void isIdempotent_whenRerunWithSameParameters() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + saveMetrics(1L, LocalDateTime.of(2026, 4, 1, 12, 0), 100L, 10L, 5L); + + var jobParameters1 = new JobParametersBuilder() + .addString("date", "20260414") + .addLong("run.id", 1L) + .toJobParameters(); + var jobParameters2 = new JobParametersBuilder() + .addString("date", "20260414") + .addLong("run.id", 2L) + .toJobParameters(); + + // act + jobLauncherTestUtils.launchJob(jobParameters1); + jobLauncherTestUtils.launchJob(jobParameters2); + + // assert + List rankings = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(1L) + ); + } + } + + private void saveMetrics(Long productId, LocalDateTime metricHour, + Long viewCount, Long likeCount, Long orderCount) { + try { + var constructor = ProductMetrics.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ProductMetrics metrics = constructor.newInstance(); + ReflectionTestUtils.setField(metrics, "productId", productId); + ReflectionTestUtils.setField(metrics, "metricHour", metricHour); + ReflectionTestUtils.setField(metrics, "viewCount", viewCount); + ReflectionTestUtils.setField(metrics, "likeCount", likeCount); + ReflectionTestUtils.setField(metrics, "orderCount", orderCount); + productMetricsJpaRepository.save(metrics); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java new file mode 100644 index 0000000000..baec6b03ea --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java @@ -0,0 +1,128 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.test.util.ReflectionTestUtils; + +import com.loopers.batch.job.ranking.step.MonthlyRankingTasklet; +import com.loopers.domain.metrics.ProductMetricsRepository; +import com.loopers.domain.metrics.ProductScoreProjection; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; + +@ExtendWith(MockitoExtension.class) +class MonthlyRankingTaskletTest { + + @InjectMocks + private MonthlyRankingTasklet tasklet; + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @DisplayName("월간 랭킹 집계를 수행할 때,") + @Nested + class Execute { + + @DisplayName("date 파라미터가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenDateIsNull() { + // arrange + ReflectionTestUtils.setField(tasklet, "date", null); + + // act & assert + assertThatThrownBy(() -> tasklet.execute( + mock(StepContribution.class), mock(ChunkContext.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("date"); + } + + @DisplayName("DB에서 조회한 Top N 스코어를 월간 랭킹으로 저장한다.") + @Test + @SuppressWarnings("unchecked") + void savesTopNScoresAsMonthlyRanking() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260414"); + + List topScores = List.of( + new ProductScoreProjection(1L, 46.5), + new ProductScoreProjection(2L, 8.1) + ); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(topScores); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankingMonthlyRepository).saveAll(captor.capture()); + + List rankings = captor.getValue(); + + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> { + ProductRankingMonthly product1 = rankings.stream() + .filter(r -> r.getProductId().equals(1L)) + .findFirst().orElseThrow(); + assertThat(product1.getScore()).isEqualTo(46.5); + assertThat(product1.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 14)); + }, + () -> { + ProductRankingMonthly product2 = rankings.stream() + .filter(r -> r.getProductId().equals(2L)) + .findFirst().orElseThrow(); + assertThat(product2.getScore()).isEqualTo(8.1); + } + ); + } + + @DisplayName("집계 범위를 올바르게 계산한다 (date 기준 30일 전 ~ 전날).") + @Test + void calculatesCorrectDateRange() throws Exception { + // arrange + ReflectionTestUtils.setField(tasklet, "date", "20260414"); + + given(productMetricsRepository.findTopScores(any(), any(), eq(100))) + .willReturn(List.of()); + + // act + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // assert + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); + + assertAll( + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 3, 15, 0, 0, 0)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 13).atTime(java.time.LocalTime.MAX)) + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java new file mode 100644 index 0000000000..1961fb7f46 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java @@ -0,0 +1,87 @@ +package com.loopers.job.ranking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import com.loopers.batch.job.ranking.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.ProductRankingMonthlyRepository; +import com.loopers.infrastructure.ranking.persistence.ProductRankingMonthlyJpaRepository; +import com.loopers.utils.DatabaseCleanUp; + +@SpringBootTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class ProductRankingMonthlyRepositoryIntegrationTest { + + @Autowired + private ProductRankingMonthlyRepository productRankingMonthlyRepository; + + @Autowired + private ProductRankingMonthlyJpaRepository productRankingMonthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("월간 랭킹을 저장할 때,") + @Nested + class SaveAll { + + @DisplayName("같은 (product_id, score_date)로 두 번 저장하면, score가 덮어쓰기된다.") + @Test + void updatesScore_whenDuplicateKey() { + // arrange + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + var firstRanking = ProductRankingMonthly.create(1L, scoreDate, 100.0); + productRankingMonthlyRepository.saveAll(List.of(firstRanking)); + + // act + var updatedRanking = ProductRankingMonthly.create(1L, scoreDate, 200.0); + productRankingMonthlyRepository.saveAll(List.of(updatedRanking)); + + // assert + List results = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).getScore()).isEqualTo(200.0) + ); + } + + @DisplayName("다른 score_date이면, 별도 row로 저장된다.") + @Test + void createsSeparateRows_whenDifferentScoreDate() { + // arrange + LocalDate date1 = LocalDate.of(2026, 4, 14); + LocalDate date2 = LocalDate.of(2026, 4, 15); + + // act + productRankingMonthlyRepository.saveAll( + List.of(ProductRankingMonthly.create(1L, date1, 100.0))); + productRankingMonthlyRepository.saveAll( + List.of(ProductRankingMonthly.create(1L, date2, 150.0))); + + // assert + List results = productRankingMonthlyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(2), + () -> assertThat(results).extracting(ProductRankingMonthly::getScore) + .containsExactlyInAnyOrder(100.0, 150.0) + ); + } + } +} From 21e03803d17acca403e19a3c98bf9db2883cc1a1 Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Wed, 15 Apr 2026 09:56:43 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20product=5Fmetrics=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=8B=A8=EC=9C=84=EB=A5=BC=20=EC=9D=BC=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../job/ranking/step/MonthlyRankingTasklet.java | 10 ++++------ .../job/ranking/step/WeeklyRankingTasklet.java | 10 ++++------ .../com/loopers/domain/metrics/ProductMetrics.java | 9 ++++++--- .../domain/metrics/ProductMetricsRepository.java | 4 ++-- .../persistence/ProductMetricsJpaRepository.java | 8 ++++---- .../persistence/ProductMetricsRepositoryImpl.java | 4 ++-- .../job/ranking/MonthlyRankingJobE2ETest.java | 13 ++++++------- .../job/ranking/MonthlyRankingTaskletTest.java | 9 ++++----- .../job/ranking/WeeklyRankingJobE2ETest.java | 13 ++++++------- .../job/ranking/WeeklyRankingTaskletTest.java | 9 ++++----- .../com/loopers/domain/metrics/ProductMetrics.java | 9 ++++++--- .../persistence/ProductMetricsJpaRepository.java | 12 ++++++------ .../ProductMetricsRepositoryImplTest.java | 11 +++++------ 13 files changed, 59 insertions(+), 62 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java index 9820b6ee24..6915bc3823 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java @@ -1,8 +1,6 @@ package com.loopers.batch.job.ranking.step; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Objects; @@ -35,8 +33,8 @@ * *

집계 범위 예시 (date=20260414):

*
- * start: 2026-03-15 00:00:00
- * end:   2026-04-13 23:59:59.999999999
+ * start: 2026-03-15
+ * end:   2026-04-13
  * 
*/ @Slf4j @@ -62,8 +60,8 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon } LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); - LocalDateTime start = baseDate.minusDays(30).atStartOfDay(); - LocalDateTime end = baseDate.minusDays(1).atTime(LocalTime.MAX); + LocalDate start = baseDate.minusDays(30); + LocalDate end = baseDate.minusDays(1); log.info("월간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java index a17095d420..32d0eca1b6 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java @@ -1,8 +1,6 @@ package com.loopers.batch.job.ranking.step; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Objects; @@ -35,8 +33,8 @@ * *

집계 범위 예시 (date=20260413):

*
- * start: 2026-04-06 00:00:00
- * end:   2026-04-12 23:59:59.999999999
+ * start: 2026-04-06
+ * end:   2026-04-12
  * 
*/ @Slf4j @@ -62,8 +60,8 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon } LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); - LocalDateTime start = baseDate.minusDays(7).atStartOfDay(); - LocalDateTime end = baseDate.minusDays(1).atTime(LocalTime.MAX); + LocalDate start = baseDate.minusDays(7); + LocalDate end = baseDate.minusDays(1); log.info("주간 랭킹 집계 시작 — 기준일: {}, 범위: {} ~ {}", baseDate, start, end); diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index 068bf8b14e..294f6a3ca7 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -1,12 +1,13 @@ package com.loopers.domain.metrics; -import java.time.LocalDateTime; +import java.time.LocalDate; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -16,7 +17,9 @@ @Entity @Table(name = "product_metrics", uniqueConstraints = { - @UniqueConstraint(columnNames = {"product_id", "metric_hour"}) + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) +}, indexes = { + @Index(name = "idx_metric_date", columnList = "metric_date") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @@ -30,7 +33,7 @@ public class ProductMetrics { private Long productId; @Column(nullable = false) - private LocalDateTime metricHour; + private LocalDate metricDate; @Column(nullable = false) private Long viewCount; diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index 0bb1959a6b..f12be63b13 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -1,9 +1,9 @@ package com.loopers.domain.metrics; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public interface ProductMetricsRepository { - List findTopScores(LocalDateTime start, LocalDateTime end, int limit); + List findTopScores(LocalDate start, LocalDate end, int limit); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java index 1b404f59c4..97665f0830 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.metrics.persistence; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.Pageable; @@ -17,12 +17,12 @@ public interface ProductMetricsJpaRepository extends JpaRepository findTopScores( - @Param("start") LocalDateTime start, - @Param("end") LocalDateTime end, + @Param("start") LocalDate start, + @Param("end") LocalDate end, Pageable pageable ); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java index 29d43135b1..61e6b856c2 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure.metrics.persistence; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import org.springframework.data.domain.PageRequest; @@ -18,7 +18,7 @@ public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { private final ProductMetricsJpaRepository productMetricsJpaRepository; @Override - public List findTopScores(LocalDateTime start, LocalDateTime end, int limit) { + public List findTopScores(LocalDate start, LocalDate end, int limit) { return productMetricsJpaRepository.findTopScores(start, end, PageRequest.of(0, limit)); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java index 04baf4c417..61382558f8 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertAll; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -80,9 +79,9 @@ void aggregatesMonthlyRanking() throws Exception { jobLauncherTestUtils.setJob(job); // 30일 범위 내 데이터 (date=20260414 → 03/15 ~ 04/13) - saveMetrics(1L, LocalDateTime.of(2026, 3, 20, 10, 0), 100L, 10L, 5L); - saveMetrics(1L, LocalDateTime.of(2026, 4, 5, 14, 0), 200L, 20L, 10L); - saveMetrics(2L, LocalDateTime.of(2026, 4, 10, 9, 0), 50L, 5L, 3L); + saveMetrics(1L, LocalDate.of(2026, 3, 20), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 5), 200L, 20L, 10L); + saveMetrics(2L, LocalDate.of(2026, 4, 10), 50L, 5L, 3L); var jobParameters = new JobParametersBuilder() .addString("date", "20260414") @@ -122,7 +121,7 @@ void isIdempotent_whenRerunWithSameParameters() throws Exception { // arrange jobLauncherTestUtils.setJob(job); - saveMetrics(1L, LocalDateTime.of(2026, 4, 1, 12, 0), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 1), 100L, 10L, 5L); var jobParameters1 = new JobParametersBuilder() .addString("date", "20260414") @@ -146,14 +145,14 @@ void isIdempotent_whenRerunWithSameParameters() throws Exception { } } - private void saveMetrics(Long productId, LocalDateTime metricHour, + private void saveMetrics(Long productId, LocalDate metricDate, Long viewCount, Long likeCount, Long orderCount) { try { var constructor = ProductMetrics.class.getDeclaredConstructor(); constructor.setAccessible(true); ProductMetrics metrics = constructor.newInstance(); ReflectionTestUtils.setField(metrics, "productId", productId); - ReflectionTestUtils.setField(metrics, "metricHour", metricHour); + ReflectionTestUtils.setField(metrics, "metricDate", metricDate); ReflectionTestUtils.setField(metrics, "viewCount", viewCount); ReflectionTestUtils.setField(metrics, "likeCount", likeCount); ReflectionTestUtils.setField(metrics, "orderCount", orderCount); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java index baec6b03ea..52218922ac 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java @@ -10,7 +10,6 @@ import static org.mockito.Mockito.verify; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -115,13 +114,13 @@ void calculatesCorrectDateRange() throws Exception { tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); // assert - ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class); verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); assertAll( - () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 3, 15, 0, 0, 0)), - () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 13).atTime(java.time.LocalTime.MAX)) + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 3, 15)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 13)) ); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java index 778f6aba64..3a1d7a9438 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertAll; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -79,9 +78,9 @@ void aggregatesWeeklyRanking() throws Exception { // arrange jobLauncherTestUtils.setJob(job); - saveMetrics(1L, LocalDateTime.of(2026, 4, 7, 10, 0), 100L, 10L, 5L); - saveMetrics(1L, LocalDateTime.of(2026, 4, 8, 14, 0), 200L, 20L, 10L); - saveMetrics(2L, LocalDateTime.of(2026, 4, 9, 9, 0), 50L, 5L, 3L); + saveMetrics(1L, LocalDate.of(2026, 4, 7), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 8), 200L, 20L, 10L); + saveMetrics(2L, LocalDate.of(2026, 4, 9), 50L, 5L, 3L); var jobParameters = new JobParametersBuilder() .addString("date", "20260413") @@ -121,7 +120,7 @@ void isIdempotent_whenRerunWithSameParameters() throws Exception { // arrange jobLauncherTestUtils.setJob(job); - saveMetrics(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100L, 10L, 5L); + saveMetrics(1L, LocalDate.of(2026, 4, 10), 100L, 10L, 5L); var jobParameters1 = new JobParametersBuilder() .addString("date", "20260413") @@ -145,14 +144,14 @@ void isIdempotent_whenRerunWithSameParameters() throws Exception { } } - private void saveMetrics(Long productId, LocalDateTime metricHour, + private void saveMetrics(Long productId, LocalDate metricDate, Long viewCount, Long likeCount, Long orderCount) { try { var constructor = ProductMetrics.class.getDeclaredConstructor(); constructor.setAccessible(true); ProductMetrics metrics = constructor.newInstance(); ReflectionTestUtils.setField(metrics, "productId", productId); - ReflectionTestUtils.setField(metrics, "metricHour", metricHour); + ReflectionTestUtils.setField(metrics, "metricDate", metricDate); ReflectionTestUtils.setField(metrics, "viewCount", viewCount); ReflectionTestUtils.setField(metrics, "likeCount", likeCount); ReflectionTestUtils.setField(metrics, "orderCount", orderCount); diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java index 00e8e87235..516cb8f6cb 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java @@ -10,7 +10,6 @@ import static org.mockito.Mockito.verify; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -115,13 +114,13 @@ void calculatesCorrectDateRange() throws Exception { tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); // assert - ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDate.class); verify(productMetricsRepository).findTopScores(startCaptor.capture(), endCaptor.capture(), eq(100)); assertAll( - () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDateTime.of(2026, 4, 6, 0, 0, 0)), - () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 12).atTime(java.time.LocalTime.MAX)) + () -> assertThat(startCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 6)), + () -> assertThat(endCaptor.getValue()).isEqualTo(LocalDate.of(2026, 4, 12)) ); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index fb9d2b8b84..fb566d3d96 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -1,6 +1,6 @@ package com.loopers.domain.metrics; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.time.ZonedDateTime; import jakarta.persistence.Column; @@ -8,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; @@ -19,7 +20,9 @@ @Entity @Table(name = "product_metrics", uniqueConstraints = { - @UniqueConstraint(columnNames = {"product_id", "metric_hour"}) + @UniqueConstraint(columnNames = {"product_id", "metric_date"}) +}, indexes = { + @Index(name = "idx_metric_date", columnList = "metric_date") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @@ -33,7 +36,7 @@ public class ProductMetrics { private Long productId; @Column(nullable = false) - private LocalDateTime metricHour; + private LocalDate metricDate; @Column(nullable = false) private Long likeCount; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java index c0ab338104..d922a184c2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java @@ -10,24 +10,24 @@ public interface ProductMetricsJpaRepository extends JpaRepository { @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), GREATEST(:delta, 0), 0, 0, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), GREATEST(:delta, 0), 0, 0, NOW()) " + "ON DUPLICATE KEY UPDATE " + "like_count = GREATEST(like_count + :delta, 0), updated_at = NOW()", nativeQuery = true) void upsertLikeCount(@Param("productId") Long productId, @Param("delta") Long delta); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, :quantity, 0, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), 0, :quantity, 0, NOW()) " + "ON DUPLICATE KEY UPDATE " + "order_count = order_count + :quantity, updated_at = NOW()", nativeQuery = true) void upsertOrderCount(@Param("productId") Long productId, @Param("quantity") Long quantity); @Modifying - @Query(value = "INSERT INTO product_metrics (product_id, metric_hour, like_count, order_count, view_count, updated_at) " - + "VALUES (:productId, DATE_FORMAT(NOW(), '%Y-%m-%d %H:00:00'), 0, 0, 1, NOW()) " + @Query(value = "INSERT INTO product_metrics (product_id, metric_date, like_count, order_count, view_count, updated_at) " + + "VALUES (:productId, CURDATE(), 0, 0, 1, NOW()) " + "ON DUPLICATE KEY UPDATE " + "view_count = view_count + 1, updated_at = NOW()", nativeQuery = true) diff --git a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java index c77c4f63f3..adbe8e0c30 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java @@ -3,8 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -33,7 +32,7 @@ class ProductMetricsRepositoryImplTest { @Autowired private DatabaseCleanUp databaseCleanUp; - private static final LocalDateTime CURRENT_METRIC_HOUR = LocalDateTime.now().truncatedTo(ChronoUnit.HOURS); + private static final LocalDate CURRENT_METRIC_DATE = LocalDate.now(); @AfterEach void tearDown() { @@ -74,7 +73,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getLikeCount()).isEqualTo(1L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); } @@ -127,7 +126,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getOrderCount()).isEqualTo(3L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); } @@ -165,7 +164,7 @@ void insertsNewRow_whenNotExists() { () -> assertThat(results).hasSize(1), () -> assertThat(results.get(0).getProductId()).isEqualTo(1L), () -> assertThat(results.get(0).getViewCount()).isEqualTo(1L), - () -> assertThat(results.get(0).getMetricHour()).isEqualTo(CURRENT_METRIC_HOUR) + () -> assertThat(results.get(0).getMetricDate()).isEqualTo(CURRENT_METRIC_DATE) ); } From cd4ddda770125933091d81365ca9f31672a8b396 Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Wed, 15 Apr 2026 13:37:11 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../ranking/ReadWeeklyRankingsUseCase.java | 48 ++++++++++ .../domain/ranking/ProductRankingWeekly.java | 45 +++++++++ .../loopers/domain/ranking/RankingItem.java | 30 +++++- .../domain/ranking/RankingService.java | 16 +++- .../ranking/WeeklyRankingRepository.java | 22 +++++ .../WeeklyRankingJpaRepository.java | 14 +++ .../WeeklyRankingRepositoryImpl.java | 31 +++++++ .../api/ranking/v1/RankingV1Api.java | 16 +++- .../api/ranking/v1/RankingV1ApiSpec.java | 6 ++ ...yRankingRepositoryImplIntegrationTest.java | 91 +++++++++++++++++++ .../api/ranking/v1/RankingSteps.java | 8 ++ .../api/ranking/v1/RankingV1ApiE2ETest.java | 81 +++++++++++++++++ 12 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java new file mode 100644 index 0000000000..c2d83140ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java @@ -0,0 +1,48 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import com.loopers.application.shared.annotation.UseCase; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.RankingItem; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.page.PageSize; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 인기 상품 랭킹을 페이지 단위로 조회한다. + * + *

배치가 집계한 지정 scoreDate의 주간 랭킹을 반환한다.

+ */ +@UseCase +@RequiredArgsConstructor +public class ReadWeeklyRankingsUseCase { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingService rankingService; + private final RankingResultAssembler rankingResultAssembler; + + /** + * @param userId 사용자 ID (비로그인 시 null) + * @param date 조회 기준일 (yyyyMMdd 형식) + * @param pageSize 페이지 정보 + * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) + */ + public RankingPageResult execute(Long userId, String date, PageSize pageSize) { + LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); + List rankings = rankingService.readWeeklyTopRanked( + scoreDate, pageSize.page(), pageSize.size()); + + List rankingItems = RankingItem.toRankingItems( + rankings, + pageSize.offset(), + ProductRankingWeekly::getProductId, + ProductRankingWeekly::getScore + ); + return rankingResultAssembler.assemble(userId, rankingItems, pageSize); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java new file mode 100644 index 0000000000..05fd2af7d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -0,0 +1,45 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly", indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingWeekly ranking = new ProductRankingWeekly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java index 5c58080d37..d04f7f546c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java @@ -1,11 +1,39 @@ package com.loopers.domain.ranking; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; + /** - * 랭킹 Sorted Set의 단일 항목. + * 랭킹 단일 항목. * * @param rank 1-based 순위 * @param productId 상품 ID * @param score 가중치 기반 누적 점수 */ public record RankingItem(int rank, Long productId, double score) { + + /** + * 엔티티 목록을 {@link RankingItem} 목록으로 변환한다. + * + * @param items 원본 목록 + * @param offset 시작 오프셋 (rank 계산용) + * @param toProductId 상품 ID 추출 함수 + * @param toScore 점수 추출 함수 + * @return 순위가 포함된 랭킹 항목 목록 + */ + public static List toRankingItems( + List items, + int offset, + Function toProductId, + ToDoubleFunction toScore + ) { + List rankingItems = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + T item = items.get(i); + rankingItems.add(new RankingItem(offset + i + 1, toProductId.apply(item), toScore.applyAsDouble(item))); + } + return rankingItems; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index 8abb838f93..b830792359 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -1,5 +1,6 @@ package com.loopers.domain.ranking; +import java.time.LocalDate; import java.util.List; import com.loopers.domain.shared.annotation.DomainService; @@ -9,13 +10,14 @@ /** * 랭킹 조회 도메인 서비스. * - *

일간·시간 단위 Redis Sorted Set에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ *

일간·시간 단위는 Redis Sorted Set, 주간은 배치 집계 DB에서 랭킹 데이터를 읽기 전용으로 제공한다.

*/ @DomainService @RequiredArgsConstructor public class RankingService { private final RankingRepository rankingRepository; + private final WeeklyRankingRepository weeklyRankingRepository; /** * 일간 상위 랭킹을 조회한다. @@ -42,4 +44,16 @@ public List readHourlyTopRanked(String datetime, int offset, int co String key = RankingKeyResolver.resolveHourly(datetime); return rankingRepository.readTopRanked(key, offset, count); } + + /** + * 주간 상위 랭킹을 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 주간 랭킹 엔티티 목록 (점수 내림차순) + */ + public List readWeeklyTopRanked(LocalDate scoreDate, int page, int size) { + return weeklyRankingRepository.readTopRanked(scoreDate, page, size); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java new file mode 100644 index 0000000000..c98373d00c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 주간 랭킹 데이터를 조회하는 포트. + * + *

배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ */ +public interface WeeklyRankingRepository { + + /** + * 지정한 scoreDate의 주간 랭킹을 점수 내림차순으로 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 주간 랭킹 엔티티 목록 (점수 내림차순) + */ + List readTopRanked(LocalDate scoreDate, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java new file mode 100644 index 0000000000..a72a9d3dab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.ranking.ProductRankingWeekly; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + List findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java new file mode 100644 index 0000000000..fc96d7b8c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 주간 랭킹 조회 구현체. + * + *

배치가 집계한 {@code mv_product_rank_weekly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.

+ */ +@Repository +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + @Override + @Transactional(readOnly = true) + public List readTopRanked(LocalDate scoreDate, int page, int size) { + return weeklyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java index f080413817..9b0fd76888 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java @@ -6,8 +6,9 @@ import org.springframework.web.bind.annotation.RestController; import com.loopers.application.ranking.RankingPageResult; -import com.loopers.application.ranking.ReadHourlyRankingsUseCase; import com.loopers.application.ranking.ReadDailyRankingsUseCase; +import com.loopers.application.ranking.ReadHourlyRankingsUseCase; +import com.loopers.application.ranking.ReadWeeklyRankingsUseCase; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.LoginUser; import com.loopers.support.page.PageSize; @@ -21,6 +22,7 @@ public class RankingV1Api implements RankingV1ApiSpec { private final ReadDailyRankingsUseCase readRankingsUseCase; private final ReadHourlyRankingsUseCase readHourlyRankingsUseCase; + private final ReadWeeklyRankingsUseCase readWeeklyRankingsUseCase; @GetMapping("/daily") @Override @@ -45,4 +47,16 @@ public ApiResponse getHourlyRankings( RankingPageResult result = readHourlyRankingsUseCase.execute(userId, datetime, PageSize.withMaxSize(page, size)); return ApiResponse.success(RankingDto.RankingResponse.from(result)); } + + @GetMapping("/weekly") + @Override + public ApiResponse getWeeklyRankings( + @LoginUser Long userId, + @RequestParam String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + RankingPageResult result = readWeeklyRankingsUseCase.execute(userId, date, PageSize.withMaxSize(page, size)); + return ApiResponse.success(RankingDto.RankingResponse.from(result)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java index 660622b7e2..7258809d9a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java @@ -22,4 +22,10 @@ public interface RankingV1ApiSpec { description = "Redis Sorted Set 기반의 시간 단위 인기 상품 랭킹을 페이지 단위로 조회합니다." ) ApiResponse getHourlyRankings(Long userId, String datetime, int page, int size); + + @Operation( + summary = "주간 인기 상품 랭킹 조회 API", + description = "배치 집계 기반의 주간 인기 상품 랭킹을 페이지 단위로 조회합니다." + ) + ApiResponse getWeeklyRankings(Long userId, String date, int page, int size); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java new file mode 100644 index 0000000000..f49a0d979d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.ranking.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import com.loopers.support.BaseIntegrationTest; + +@DisplayName("WeeklyRankingRepositoryImpl 통합 테스트") +class WeeklyRankingRepositoryImplIntegrationTest extends BaseIntegrationTest { + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + + private static final LocalDate SCORE_DATE = LocalDate.of(2026, 4, 14); + + @DisplayName("상위 랭킹을 조회할 때,") + @Nested + class ReadTopRanked { + + @DisplayName("해당 scoreDate의 데이터를 score 내림차순으로 반환한다.") + @Test + void returnsItemsByScoreDateInDescendingOrder() { + // arrange + LocalDate otherDate = LocalDate.of(2026, 4, 13); + saveRanking(1L, otherDate, 99.0); + saveRanking(10L, SCORE_DATE, 70.0); + saveRanking(20L, SCORE_DATE, 58.4); + saveRanking(30L, SCORE_DATE, 45.2); + + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(3), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(10L), + () -> assertThat(rankings.get(0).getScore()).isEqualTo(70.0), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(20L), + () -> assertThat(rankings.get(2).getProductId()).isEqualTo(30L) + ); + } + + @DisplayName("page를 지정하면, 해당 페이지의 데이터를 반환한다.") + @Test + void returnsItemsByPage() { + // arrange + saveRanking(1L, SCORE_DATE, 70.0); + saveRanking(2L, SCORE_DATE, 58.4); + saveRanking(3L, SCORE_DATE, 45.2); + saveRanking(4L, SCORE_DATE, 30.0); + saveRanking(5L, SCORE_DATE, 15.5); + + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 1, 2); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(3L), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(4L) + ); + } + + @DisplayName("데이터가 없으면, 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoData() { + // act + List rankings = weeklyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertThat(rankings).isEmpty(); + } + } + + private void saveRanking(Long productId, LocalDate scoreDate, Double score) { + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId, scoreDate, score)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java index 5f525a5d88..69108a1d72 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java @@ -12,6 +12,7 @@ public class RankingSteps { private static final String DAILY_ENDPOINT = "/api/v1/rankings/daily"; private static final String HOURLY_ENDPOINT = "/api/v1/rankings/hourly"; + private static final String WEEKLY_ENDPOINT = "/api/v1/rankings/weekly"; public static ResponseEntity> getDailyRankings( TestRestTemplate testRestTemplate, @@ -27,6 +28,13 @@ public static ResponseEntity> getHourlyR return doGet(testRestTemplate, HOURLY_ENDPOINT, queryParams); } + public static ResponseEntity> getWeeklyRankings( + TestRestTemplate testRestTemplate, + String queryParams + ) { + return doGet(testRestTemplate, WEEKLY_ENDPOINT, queryParams); + } + private static ResponseEntity> doGet( TestRestTemplate testRestTemplate, String endpoint, diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java index 38a843c1ed..c52e2540db 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java @@ -2,6 +2,7 @@ import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getDailyRankings; import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getHourlyRankings; +import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getWeeklyRankings; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -19,6 +20,8 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; +import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.infrastructure.ranking.persistence.WeeklyRankingJpaRepository; import com.loopers.interfaces.api.brand.v1.BrandDto; import com.loopers.interfaces.api.brand.v1.BrandSteps; import com.loopers.interfaces.api.product.v1.ProductDto; @@ -31,6 +34,9 @@ class RankingV1ApiE2ETest extends BaseE2ETest { @Autowired private RedisTemplate redisTemplate; + @Autowired + private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + @DisplayName("GET /api/v1/rankings/daily") @Nested class DailyRankings { @@ -229,4 +235,79 @@ void returnsRankingsForSpecificDatetime() { ); } } + + @DisplayName("GET /api/v1/rankings/weekly") + @Nested + class WeeklyRankings { + + private static final String DATE = "20260414"; + + @DisplayName("주간 랭킹 데이터가 있으면,") + @Nested + class WhenRankingDataExists { + + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setUp() { + Long brandId = BrandSteps.createBrand( + testRestTemplate, + new BrandDto.CreateBrandRequest("테스트 브랜드", "https://example.com/logo.png", null) + ); + productId1 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품1", "https://example.com/1.png", 50000L, 100L, null)); + productId2 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품2", "https://example.com/2.png", 30000L, 100L, null)); + productId3 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품3", "https://example.com/3.png", 10000L, 100L, null)); + + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId1, scoreDate, 70.0)); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId2, scoreDate, 58.4)); + weeklyRankingJpaRepository.save(ProductRankingWeekly.create(productId3, scoreDate, 45.2)); + } + + @DisplayName("순위순으로 상품 정보(brandId, liked 포함)를 반환한다.") + @Test + void returnsRankedProducts() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE); + var first = response.getBody().data().rankings().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(first.rank()).isEqualTo(1), + () -> assertThat(first.productId()).isEqualTo(productId1), + () -> assertThat(first.productName()).isEqualTo("상품1"), + () -> assertThat(first.brandId()).isNotNull(), + () -> assertThat(first.liked()).isFalse() + ); + } + + @DisplayName("페이지네이션이 적용된다.") + @Test + void supportsPagination() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE + "&page=1&size=2"); + List rankings = response.getBody().data().rankings(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).rank()).isEqualTo(3), + () -> assertThat(rankings.get(0).productId()).isEqualTo(productId3), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().size()).isEqualTo(2) + ); + } + } + + @DisplayName("해당 date에 데이터가 없으면, 빈 배열을 반환한다.") + @Test + void returnsEmptyRankings_whenNoData() { + var response = getWeeklyRankings(testRestTemplate, "date=" + DATE); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + } } From 2f8e244a0fb94be4591e8bb70e4061079ea4b67a Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Wed, 15 Apr 2026 14:08:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../ranking/ReadMonthlyRankingsUseCase.java | 46 ++++++++++ .../ranking/MonthlyRankingRepository.java | 22 +++++ .../domain/ranking/ProductRankingMonthly.java | 50 ++++++++++ .../domain/ranking/RankingService.java | 15 ++- .../MonthlyRankingJpaRepository.java | 14 +++ .../MonthlyRankingRepositoryImpl.java | 31 +++++++ .../api/ranking/v1/RankingV1Api.java | 14 +++ .../api/ranking/v1/RankingV1ApiSpec.java | 6 ++ ...yRankingRepositoryImplIntegrationTest.java | 91 +++++++++++++++++++ .../api/ranking/v1/RankingSteps.java | 8 ++ .../api/ranking/v1/RankingV1ApiE2ETest.java | 90 ++++++++++++++++++ 11 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java new file mode 100644 index 0000000000..d6620876d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java @@ -0,0 +1,46 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import com.loopers.application.shared.annotation.UseCase; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.domain.ranking.RankingItem; +import com.loopers.domain.ranking.RankingService; +import com.loopers.support.page.PageSize; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 인기 상품 랭킹을 페이지 단위로 조회한다. + * + *

배치가 집계한 지정 scoreDate의 월간 랭킹을 반환한다.

+ */ +@UseCase +@RequiredArgsConstructor +public class ReadMonthlyRankingsUseCase { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final RankingService rankingService; + private final RankingResultAssembler rankingResultAssembler; + + /** + * @param userId 사용자 ID (비로그인 시 null) + * @param date 조회 기준일 (yyyyMMdd 형식) + * @param pageSize 페이지 정보 + * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) + */ + public RankingPageResult execute(Long userId, String date, PageSize pageSize) { + LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); + List rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size()); + List rankingItems = RankingItem.toRankingItems( + rankings, + pageSize.offset(), + ProductRankingMonthly::getProductId, + ProductRankingMonthly::getScore + ); + return rankingResultAssembler.assemble(userId, rankingItems, pageSize); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java new file mode 100644 index 0000000000..0716560677 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 월간 랭킹 데이터를 조회하는 포트. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ */ +public interface MonthlyRankingRepository { + + /** + * 지정한 scoreDate의 월간 랭킹을 점수 내림차순으로 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 월간 랭킹 엔티티 목록 (점수 내림차순) + */ + List readTopRanked(LocalDate scoreDate, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java new file mode 100644 index 0000000000..60bb3250bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -0,0 +1,50 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 월간 인기 상품 랭킹 엔티티. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블의 읽기 전용 매핑이다.

+ */ +@Entity +@Table(name = "mv_product_rank_monthly", indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankingMonthly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDate scoreDate; + + @Column(nullable = false) + private Double score; + + public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) { + ProductRankingMonthly ranking = new ProductRankingMonthly(); + ranking.productId = productId; + ranking.scoreDate = scoreDate; + ranking.score = score; + return ranking; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index b830792359..d430317834 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -10,7 +10,7 @@ /** * 랭킹 조회 도메인 서비스. * - *

일간·시간 단위는 Redis Sorted Set, 주간은 배치 집계 DB에서 랭킹 데이터를 읽기 전용으로 제공한다.

+ *

일간·시간 단위는 Redis Sorted Set, 주간·월간은 배치 집계 DB에서 랭킹 데이터를 읽기 전용으로 제공한다.

*/ @DomainService @RequiredArgsConstructor @@ -18,6 +18,7 @@ public class RankingService { private final RankingRepository rankingRepository; private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; /** * 일간 상위 랭킹을 조회한다. @@ -56,4 +57,16 @@ public List readHourlyTopRanked(String datetime, int offset, int co public List readWeeklyTopRanked(LocalDate scoreDate, int page, int size) { return weeklyRankingRepository.readTopRanked(scoreDate, page, size); } + + /** + * 월간 상위 랭킹을 조회한다. + * + * @param scoreDate 조회 기준일 + * @param page 페이지 번호 (0-based) + * @param size 페이지 크기 + * @return 월간 랭킹 엔티티 목록 (점수 내림차순) + */ + public List readMonthlyTopRanked(LocalDate scoreDate, int page, int size) { + return monthlyRankingRepository.readTopRanked(scoreDate, page, size); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java new file mode 100644 index 0000000000..4a5117f48b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.loopers.domain.ranking.ProductRankingMonthly; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + List findByScoreDateOrderByScoreDesc(LocalDate scoreDate, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java new file mode 100644 index 0000000000..7dbbd095af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.ranking.persistence; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.ProductRankingMonthly; + +import lombok.RequiredArgsConstructor; + +/** + * 월간 랭킹 조회 구현체. + * + *

배치가 집계한 {@code mv_product_rank_monthly} 테이블에서 지정한 scoreDate의 랭킹을 조회한다.

+ */ +@Repository +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + @Override + @Transactional(readOnly = true) + public List readTopRanked(LocalDate scoreDate, int page, int size) { + return monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java index 9b0fd76888..ff527f618f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java @@ -8,6 +8,7 @@ import com.loopers.application.ranking.RankingPageResult; import com.loopers.application.ranking.ReadDailyRankingsUseCase; import com.loopers.application.ranking.ReadHourlyRankingsUseCase; +import com.loopers.application.ranking.ReadMonthlyRankingsUseCase; import com.loopers.application.ranking.ReadWeeklyRankingsUseCase; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.LoginUser; @@ -23,6 +24,7 @@ public class RankingV1Api implements RankingV1ApiSpec { private final ReadDailyRankingsUseCase readRankingsUseCase; private final ReadHourlyRankingsUseCase readHourlyRankingsUseCase; private final ReadWeeklyRankingsUseCase readWeeklyRankingsUseCase; + private final ReadMonthlyRankingsUseCase readMonthlyRankingsUseCase; @GetMapping("/daily") @Override @@ -59,4 +61,16 @@ public ApiResponse getWeeklyRankings( RankingPageResult result = readWeeklyRankingsUseCase.execute(userId, date, PageSize.withMaxSize(page, size)); return ApiResponse.success(RankingDto.RankingResponse.from(result)); } + + @GetMapping("/monthly") + @Override + public ApiResponse getMonthlyRankings( + @LoginUser Long userId, + @RequestParam String date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + RankingPageResult result = readMonthlyRankingsUseCase.execute(userId, date, PageSize.withMaxSize(page, size)); + return ApiResponse.success(RankingDto.RankingResponse.from(result)); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java index 7258809d9a..aefe85408d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java @@ -28,4 +28,10 @@ public interface RankingV1ApiSpec { description = "배치 집계 기반의 주간 인기 상품 랭킹을 페이지 단위로 조회합니다." ) ApiResponse getWeeklyRankings(Long userId, String date, int page, int size); + + @Operation( + summary = "월간 인기 상품 랭킹 조회 API", + description = "배치 집계 기반의 월간 인기 상품 랭킹을 페이지 단위로 조회합니다." + ) + ApiResponse getMonthlyRankings(Long userId, String date, int page, int size); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java new file mode 100644 index 0000000000..36e326fb49 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.infrastructure.ranking.persistence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.ProductRankingMonthly; +import com.loopers.support.BaseIntegrationTest; + +@DisplayName("MonthlyRankingRepositoryImpl 통합 테스트") +class MonthlyRankingRepositoryImplIntegrationTest extends BaseIntegrationTest { + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + + private static final LocalDate SCORE_DATE = LocalDate.of(2026, 4, 14); + + @DisplayName("상위 랭킹을 조회할 때,") + @Nested + class ReadTopRanked { + + @DisplayName("해당 scoreDate의 데이터를 score 내림차순으로 반환한다.") + @Test + void returnsItemsByScoreDateInDescendingOrder() { + // arrange + LocalDate otherDate = LocalDate.of(2026, 4, 13); + saveRanking(1L, otherDate, 99.0); + saveRanking(10L, SCORE_DATE, 70.0); + saveRanking(20L, SCORE_DATE, 58.4); + saveRanking(30L, SCORE_DATE, 45.2); + + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(3), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(10L), + () -> assertThat(rankings.get(0).getScore()).isEqualTo(70.0), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(20L), + () -> assertThat(rankings.get(2).getProductId()).isEqualTo(30L) + ); + } + + @DisplayName("page를 지정하면, 해당 페이지의 데이터를 반환한다.") + @Test + void returnsItemsByPage() { + // arrange + saveRanking(1L, SCORE_DATE, 70.0); + saveRanking(2L, SCORE_DATE, 58.4); + saveRanking(3L, SCORE_DATE, 45.2); + saveRanking(4L, SCORE_DATE, 30.0); + saveRanking(5L, SCORE_DATE, 15.5); + + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 1, 2); + + // assert + assertAll( + () -> assertThat(rankings).hasSize(2), + () -> assertThat(rankings.get(0).getProductId()).isEqualTo(3L), + () -> assertThat(rankings.get(1).getProductId()).isEqualTo(4L) + ); + } + + @DisplayName("데이터가 없으면, 빈 리스트를 반환한다.") + @Test + void returnsEmptyList_whenNoData() { + // act + List rankings = monthlyRankingRepository.readTopRanked(SCORE_DATE, 0, 10); + + // assert + assertThat(rankings).isEmpty(); + } + } + + private void saveRanking(Long productId, LocalDate scoreDate, Double score) { + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId, scoreDate, score)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java index 69108a1d72..9f87f5becf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java @@ -13,6 +13,7 @@ public class RankingSteps { private static final String DAILY_ENDPOINT = "/api/v1/rankings/daily"; private static final String HOURLY_ENDPOINT = "/api/v1/rankings/hourly"; private static final String WEEKLY_ENDPOINT = "/api/v1/rankings/weekly"; + private static final String MONTHLY_ENDPOINT = "/api/v1/rankings/monthly"; public static ResponseEntity> getDailyRankings( TestRestTemplate testRestTemplate, @@ -35,6 +36,13 @@ public static ResponseEntity> getWeeklyR return doGet(testRestTemplate, WEEKLY_ENDPOINT, queryParams); } + public static ResponseEntity> getMonthlyRankings( + TestRestTemplate testRestTemplate, + String queryParams + ) { + return doGet(testRestTemplate, MONTHLY_ENDPOINT, queryParams); + } + private static ResponseEntity> doGet( TestRestTemplate testRestTemplate, String endpoint, diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java index c52e2540db..92f9cadae1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java @@ -2,6 +2,7 @@ import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getDailyRankings; import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getHourlyRankings; +import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getMonthlyRankings; import static com.loopers.interfaces.api.ranking.v1.RankingSteps.getWeeklyRankings; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -20,7 +21,9 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; +import com.loopers.domain.ranking.ProductRankingMonthly; import com.loopers.domain.ranking.ProductRankingWeekly; +import com.loopers.infrastructure.ranking.persistence.MonthlyRankingJpaRepository; import com.loopers.infrastructure.ranking.persistence.WeeklyRankingJpaRepository; import com.loopers.interfaces.api.brand.v1.BrandDto; import com.loopers.interfaces.api.brand.v1.BrandSteps; @@ -37,6 +40,9 @@ class RankingV1ApiE2ETest extends BaseE2ETest { @Autowired private WeeklyRankingJpaRepository weeklyRankingJpaRepository; + @Autowired + private MonthlyRankingJpaRepository monthlyRankingJpaRepository; + @DisplayName("GET /api/v1/rankings/daily") @Nested class DailyRankings { @@ -310,4 +316,88 @@ void returnsEmptyRankings_whenNoData() { ); } } + + @DisplayName("GET /api/v1/rankings/monthly") + @Nested + class MonthlyRankings { + + private static final String DATE = "20260414"; + + @DisplayName("월간 랭킹 데이터가 있으면,") + @Nested + class WhenRankingDataExists { + + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setUp() { + Long brandId = BrandSteps.createBrand( + testRestTemplate, + new BrandDto.CreateBrandRequest("테스트 브랜드", "https://example.com/logo.png", null) + ); + productId1 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품1", "https://example.com/1.png", 50000L, 100L, null)); + productId2 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품2", "https://example.com/2.png", 30000L, 100L, null)); + productId3 = ProductSteps.createProduct(testRestTemplate, + new ProductDto.CreateProductRequest(brandId, "상품3", "https://example.com/3.png", 10000L, 100L, null)); + + LocalDate scoreDate = LocalDate.of(2026, 4, 14); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId1, scoreDate, 70.0)); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId2, scoreDate, 58.4)); + monthlyRankingJpaRepository.save(ProductRankingMonthly.create(productId3, scoreDate, 45.2)); + } + + @DisplayName("순위순으로 상품 정보(brandId, liked 포함)를 반환한다.") + @Test + void returnsRankedProducts() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE); + + // assert + var first = response.getBody().data().rankings().get(0); + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).hasSize(3), + () -> assertThat(first.rank()).isEqualTo(1), + () -> assertThat(first.productId()).isEqualTo(productId1), + () -> assertThat(first.productName()).isEqualTo("상품1"), + () -> assertThat(first.brandId()).isNotNull(), + () -> assertThat(first.liked()).isFalse() + ); + } + + @DisplayName("페이지네이션이 적용된다.") + @Test + void supportsPagination() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE + "&page=1&size=2"); + + // assert + List rankings = response.getBody().data().rankings(); + assertAll( + () -> assertThat(rankings).hasSize(1), + () -> assertThat(rankings.get(0).rank()).isEqualTo(3), + () -> assertThat(rankings.get(0).productId()).isEqualTo(productId3), + () -> assertThat(response.getBody().data().page()).isEqualTo(1), + () -> assertThat(response.getBody().data().size()).isEqualTo(2) + ); + } + } + + @DisplayName("해당 date에 데이터가 없으면, 빈 배열을 반환한다.") + @Test + void returnsEmptyRankings_whenNoData() { + // act + var response = getMonthlyRankings(testRestTemplate, "date=" + DATE); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().rankings()).isEmpty() + ); + } + } } From 479004dfef8e3ec0ddd929f3e047aeb23248c82a Mon Sep 17 00:00:00 2001 From: leeedohyun Date: Fri, 17 Apr 2026 10:55:36 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC,=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EC=84=A0=EC=96=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UseCase의 DateTimeParseException을 CoreException으로 래핑하여 400 응답 반환 - Tasklet의 DateTimeParseException을 IllegalArgumentException으로 래핑 - 인프라 레이어 @Transactional(readOnly=true) 제거 (프로젝트 규칙 준수) - @Modifying에 clearAutomatically/flushAutomatically 추가 - @Index 선언을 batch 모듈(테이블 소유자)로 이동, api 모듈에서 제거 - E2E 테스트 flaky 방지를 위해 run.id 파라미터 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ranking/ReadMonthlyRankingsUseCase.java | 10 +++++++++- .../application/ranking/ReadWeeklyRankingsUseCase.java | 10 +++++++++- .../loopers/domain/ranking/ProductRankingMonthly.java | 5 +---- .../loopers/domain/ranking/ProductRankingWeekly.java | 5 +---- .../persistence/MonthlyRankingRepositoryImpl.java | 2 -- .../persistence/WeeklyRankingRepositoryImpl.java | 2 -- .../batch/job/ranking/step/MonthlyRankingTasklet.java | 8 +++++++- .../batch/job/ranking/step/WeeklyRankingTasklet.java | 8 +++++++- .../loopers/domain/ranking/ProductRankingMonthly.java | 3 +++ .../loopers/domain/ranking/ProductRankingWeekly.java | 3 +++ .../ProductRankingMonthlyJpaRepository.java | 2 +- .../persistence/ProductRankingWeeklyJpaRepository.java | 2 +- .../loopers/job/ranking/MonthlyRankingJobE2ETest.java | 5 ++++- .../loopers/job/ranking/WeeklyRankingJobE2ETest.java | 5 ++++- 14 files changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java index d6620876d6..b38f2e84bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java @@ -2,12 +2,15 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import com.loopers.application.shared.annotation.UseCase; import com.loopers.domain.ranking.ProductRankingMonthly; import com.loopers.domain.ranking.RankingItem; import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.support.page.PageSize; import lombok.RequiredArgsConstructor; @@ -33,7 +36,12 @@ public class ReadMonthlyRankingsUseCase { * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) */ public RankingPageResult execute(Long userId, String date, PageSize pageSize) { - LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); + LocalDate scoreDate; + try { + scoreDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); + } List rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size()); List rankingItems = RankingItem.toRankingItems( rankings, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java index c2d83140ec..0f8503921d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java @@ -2,12 +2,15 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import com.loopers.application.shared.annotation.UseCase; import com.loopers.domain.ranking.ProductRankingWeekly; import com.loopers.domain.ranking.RankingItem; import com.loopers.domain.ranking.RankingService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.support.page.PageSize; import lombok.RequiredArgsConstructor; @@ -33,7 +36,12 @@ public class ReadWeeklyRankingsUseCase { * @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함) */ public RankingPageResult execute(Long userId, String date, PageSize pageSize) { - LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT); + LocalDate scoreDate; + try { + scoreDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT); + } List rankings = rankingService.readWeeklyTopRanked( scoreDate, pageSize.page(), pageSize.size()); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java index 60bb3250bd..550af352f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -7,7 +7,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -20,9 +19,7 @@ *

배치가 집계한 {@code mv_product_rank_monthly} 테이블의 읽기 전용 매핑이다.

*/ @Entity -@Table(name = "mv_product_rank_monthly", indexes = { - @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") -}) +@Table(name = "mv_product_rank_monthly") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class ProductRankingMonthly { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java index 05fd2af7d3..3e7cdd33b6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -7,7 +7,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -15,9 +14,7 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "mv_product_rank_weekly", indexes = { - @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") -}) +@Table(name = "mv_product_rank_weekly") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class ProductRankingWeekly { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java index 7dbbd095af..3f4b8a5845 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; import com.loopers.domain.ranking.MonthlyRankingRepository; import com.loopers.domain.ranking.ProductRankingMonthly; @@ -24,7 +23,6 @@ public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { private final MonthlyRankingJpaRepository monthlyRankingJpaRepository; @Override - @Transactional(readOnly = true) public List readTopRanked(LocalDate scoreDate, int page, int size) { return monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java index fc96d7b8c6..46745ae765 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; import com.loopers.domain.ranking.ProductRankingWeekly; import com.loopers.domain.ranking.WeeklyRankingRepository; @@ -24,7 +23,6 @@ public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { private final WeeklyRankingJpaRepository weeklyRankingJpaRepository; @Override - @Transactional(readOnly = true) public List readTopRanked(LocalDate scoreDate, int page, int size) { return weeklyRankingJpaRepository.findByScoreDateOrderByScoreDesc(scoreDate, PageRequest.of(page, size)); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java index 6915bc3823..4159e06a1f 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.Objects; @@ -59,7 +60,12 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon throw new IllegalArgumentException("JobParameter 'date' is required"); } - LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); + LocalDate baseDate; + try { + baseDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e); + } LocalDate start = baseDate.minusDays(30); LocalDate end = baseDate.minusDays(1); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java index 32d0eca1b6..419865cde7 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.List; import java.util.Objects; @@ -59,7 +60,12 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon throw new IllegalArgumentException("JobParameter 'date' is required"); } - LocalDate baseDate = LocalDate.parse(date, DATE_FORMAT); + LocalDate baseDate; + try { + baseDate = LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("JobParameter 'date' must be yyyyMMdd format: " + date, e); + } LocalDate start = baseDate.minusDays(7); LocalDate end = baseDate.minusDays(1); diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java index 741c60584d..34109ac3ea 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -17,6 +18,8 @@ @Entity @Table(name = "mv_product_rank_monthly", uniqueConstraints = { @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}, indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java index a9aacbedf2..fb179b6d00 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -17,6 +18,8 @@ @Entity @Table(name = "mv_product_rank_weekly", uniqueConstraints = { @UniqueConstraint(columnNames = {"product_id", "score_date"}) +}, indexes = { + @Index(name = "idx_score_date_score", columnList = "score_date, score DESC") }) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java index 2ab438af21..2aeec6e9f5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java @@ -11,7 +11,7 @@ public interface ProductRankingMonthlyJpaRepository extends JpaRepository { - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = "INSERT INTO mv_product_rank_monthly (product_id, score_date, score) " + "VALUES (:productId, :scoreDate, :score) " diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java index f655e24b68..159e55f090 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java @@ -11,7 +11,7 @@ public interface ProductRankingWeeklyJpaRepository extends JpaRepository { - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) " + "VALUES (:productId, :scoreDate, :score) " diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java index 61382558f8..cba14f8aa0 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java @@ -65,7 +65,10 @@ void failsJob_whenDateParameterIsMissing() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobExecution = jobLauncherTestUtils.launchJob(); + var jobParameters = new JobParametersBuilder() + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); // assert assertThat(jobExecution.getExitStatus().getExitCode()) diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java index 3a1d7a9438..c0500afda0 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -65,7 +65,10 @@ void failsJob_whenDateParameterIsMissing() throws Exception { jobLauncherTestUtils.setJob(job); // act - var jobExecution = jobLauncherTestUtils.launchJob(); + var jobParameters = new JobParametersBuilder() + .addLong("run.id", System.nanoTime()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); // assert assertThat(jobExecution.getExitStatus().getExitCode())