From b7b30ae7d79c86ad6f7edbbe50a241087bb5772c Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Thu, 16 Apr 2026 23:14:08 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20Spring=20Batch=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?/=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84?= =?UTF-8?q?=20Job=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeeklyRankingJobConfig, MonthlyRankingJobConfig: JdbcCursorItemReader + Chunk 기반 집계 Job - SQL GROUP BY 집계 + LN(1+x) 가중치 공식을 Reader 단에서 처리해 Chunk 구조에 자연스럽게 적합 - DELETE + INSERT 트랜잭션으로 base_date 단위 원자적 MV 갱신 (슬라이딩 윈도우 방식) - JobListener, ChunkListener: Job 소요 시간 및 Chunk 처리 건수 로깅 추가 - RankingWeightsConfig: view/like/orderAmount 가중치 설정값 바인딩 - application.yml: job.name 파라미터 기반 단일 Job 실행 + 외부 트리거(K8s CronJob/Jenkins) 지원 - WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest: Testcontainers 기반 E2E 검증 - scripts/run-weekly-ranking.sh, run-monthly-ranking.sh: Jenkins/Cron 트리거 실행 스크립트 Co-Authored-By: Claude Sonnet 4.6 --- .../job/monthly/MonthlyRankingJobConfig.java | 164 ++++++++++++ .../step/MonthlyRankingItemWriter.java | 57 ++++ .../job/weekly/WeeklyRankingJobConfig.java | 172 ++++++++++++ .../weekly/step/WeeklyRankingItemWriter.java | 62 +++++ .../loopers/batch/listener/ChunkListener.java | 5 +- .../loopers/batch/listener/JobListener.java | 2 +- .../loopers/config/RankingWeightsConfig.java | 42 +++ .../ranking/MvProductRankRepository.java | 28 ++ .../domain/ranking/MvProductRankRow.java | 21 ++ .../ranking/ProductMetricsAggregate.java | 25 ++ .../ranking/JdbcMvProductRankRepository.java | 79 ++++++ .../src/main/resources/application.yml | 6 + .../job/monthly/MonthlyRankingJobE2ETest.java | 250 ++++++++++++++++++ .../job/weekly/WeeklyRankingJobE2ETest.java | 250 ++++++++++++++++++ .../src/test/resources/application.yml | 12 + scripts/run-monthly-ranking.sh | 38 +++ scripts/run-weekly-ranking.sh | 38 +++ 17 files changed, 1248 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/config/RankingWeightsConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregate.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java create mode 100755 scripts/run-monthly-ranking.sh create mode 100755 scripts/run-weekly-ranking.sh diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..dd24bedde3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java @@ -0,0 +1,164 @@ +package com.loopers.batch.job.monthly; + +import com.loopers.batch.job.monthly.step.MonthlyRankingItemWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import com.loopers.config.RankingWeightsConfig.RankingWeights; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 월간 랭킹 집계 배치 Job 설정. + * + * 실행 조건: + * spring.batch.job.name=monthlyRankingJob 일 때만 Bean 이 활성화된다. + * + * 필수 JobParameter: + * targetDate (LocalDate) — 집계 기준 상한일. 슬라이딩 윈도우는 [targetDate-30, targetDate) 이다. + * + * 처리 흐름: + * 1. Reader: product_metrics_hourly 를 30일 윈도우로 집계하여 score DESC 100건 커서 조회 + * 2. Writer: Chunk 순서(=rank) + targetDate-1 의 base_date 로 mv_product_rank_monthly 에 저장 + * + * WeeklyRankingJobConfig 와 구조가 동일하며, 집계 윈도우(30일)와 대상 테이블만 다르다. + */ +@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"; + + /** + * 집계 상위 N 건. SQL LIMIT 과 chunk size 를 동일 상수로 묶어 + * 둘 중 하나만 변경되는 실수를 방지한다. + * + * LIMIT = TOP_N, chunk size = TOP_N 이 일치해야 결과 전체가 단일 Chunk 로 처리되고 + * MonthlyRankingItemWriter 의 rank 할당(1부터 순서대로)이 올바르게 동작한다. + */ + public static final int TOP_N = 100; + + /** + * 슬라이딩 윈도우 30일 집계 SQL. + * + * 일간 랭킹(RankingScoreCalculator)과 동일한 공식을 사용한다. + * score = LN(1 + SUM(view_count)) * weightView + * + LN(1 + SUM(like_count)) * weightLike + * + LN(1 + SUM(order_amount)) * weightOrder + * + * order_count 가 아닌 order_amount 를 사용하는 이유 (week9.md A-3 참고): + * 건수는 매출 기여를 무시하고, 금액 원본은 스케일 폭주로 view/like 를 압도한다. + * LN(1 + amount) 로 스케일을 압축하면 매출을 반영하면서 가중치 의미도 유지된다. + * + * 파라미터 순서: + * 1: weightView (view_count 가중치) + * 2: weightLike (like_count 가중치) + * 3: weightOrder (order_amount 가중치) + * 4: startTime (targetDate - 30일 00:00, 포함) + * 5: endTime (targetDate 00:00, 미포함) + */ + private static final String AGGREGATION_SQL = """ + SELECT + product_id, + LN(1 + SUM(view_count)) * ? + + LN(1 + SUM(like_count)) * ? + + LN(1 + SUM(order_amount)) * ? AS score + FROM product_metrics_hourly + WHERE bucket_hour >= ? AND bucket_hour < ? + GROUP BY product_id + ORDER BY score DESC + LIMIT %d + """.formatted(TOP_N); + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final MonthlyRankingItemWriter monthlyRankingItemWriter; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep()) + .listener(jobListener) + .build(); + } + + /** + * 월간 랭킹 Step. + * + * chunk size = TOP_N = LIMIT 이므로 쿼리 결과 전체가 단일 write() 호출로 처리된다. + * Writer 는 Chunk 순서를 기반으로 rank 를 1 부터 할당한다. + * + * @JobScope 를 적용하여 Job 실행마다 독립적인 Step 인스턴스를 생성한다. + */ + @JobScope + @Bean(STEP_NAME) + public Step monthlyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(TOP_N, transactionManager) + .reader(monthlyRankingReader(null, null, null)) + .writer(monthlyRankingItemWriter) + .listener(stepMonitorListener) + .build(); + } + + /** + * 월간 집계 JdbcCursorItemReader. + * + * @StepScope 로 Job 실행 시점에 targetDate 를 바인딩한다. + * + * @param dataSource @StepScope 에서 주입 + * @param rankingWeights @StepScope 에서 주입 + * @param targetDate JobParameter 에서 바인딩 + */ + @StepScope + @Bean("monthlyRankingReader") + public JdbcCursorItemReader monthlyRankingReader( + DataSource dataSource, + RankingWeights rankingWeights, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + // 슬라이딩 윈도우: targetDate 기준 직전 30일 + LocalDateTime startTime = targetDate.minusDays(30).atStartOfDay(); + LocalDateTime endTime = targetDate.atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyRankingReader") + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .preparedStatementSetter(ps -> { + ps.setDouble(1, rankingWeights.view()); + ps.setDouble(2, rankingWeights.like()); + ps.setDouble(3, rankingWeights.order()); + ps.setObject(4, startTime); + ps.setObject(5, endTime); + }) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregate( + rs.getLong("product_id"), + rs.getDouble("score") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java new file mode 100644 index 0000000000..df1ad3dacb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java @@ -0,0 +1,57 @@ +package com.loopers.batch.job.monthly.step; + +import com.loopers.batch.job.monthly.MonthlyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.MvProductRankRow; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * 월간 랭킹 MV 테이블 Writer. + * + * Reader SQL 이 score DESC 로 정렬되어 있으므로 Chunk 의 순서가 곧 랭킹 순서다. + * rank(1, 2, 3...) 를 부여하고 DELETE + INSERT 방식으로 mv_product_rank_monthly 를 갱신한다. + * + * WeeklyRankingItemWriter 와 로직이 동일하며, 대상 저장소 메서드만 다르다. + * + * baseDate = targetDate - 1일 (어제). + * API 는 date 파라미터 생략 시 어제를 기준으로 조회하므로 일관성이 유지된다. + */ +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class MonthlyRankingItemWriter implements ItemWriter { + + // JobParameter 에서 주입. targetDate - 1일이 MV 의 base_date 가 된다. + @Value("#{jobParameters['targetDate']}") + private LocalDate targetDate; + + private final MvProductRankRepository mvProductRankRepository; + + @Override + public void write(Chunk chunk) { + LocalDate baseDate = targetDate.minusDays(1); + + List rows = new ArrayList<>(); + int rank = 1; + // SQL ORDER BY score DESC 가 이미 적용되어 있으므로 순서 = rank. + // MonthlyRankingJobConfig.TOP_N = chunk size = LIMIT 이 일치해야 단일 Chunk 가 보장된다. + // 다중 Chunk 가 되면 매 write() 호출마다 rank 가 1 부터 재시작되므로 반드시 단일 Chunk 를 유지할 것. + for (ProductMetricsAggregate item : chunk.getItems()) { + rows.add(new MvProductRankRow(item.productId(), rank++, item.score())); + } + + mvProductRankRepository.replaceMonthlyRanking(baseDate, rows); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..b1c7e62805 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java @@ -0,0 +1,172 @@ +package com.loopers.batch.job.weekly; + +import com.loopers.batch.job.weekly.step.WeeklyRankingItemWriter; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import com.loopers.config.RankingWeightsConfig.RankingWeights; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 주간 랭킹 집계 배치 Job 설정. + * + * 실행 조건: + * spring.batch.job.name=weeklyRankingJob 일 때만 Bean 이 활성화된다. + * (@ConditionalOnProperty 로 Job 별 컨텍스트 격리) + * + * 필수 JobParameter: + * targetDate (LocalDate) — 집계 기준 상한일. 슬라이딩 윈도우는 [targetDate-7, targetDate) 이다. + * + * 처리 흐름: + * 1. Reader: product_metrics_hourly 를 7일 윈도우로 집계하여 score DESC 100건 커서 조회 + * 2. Writer: Chunk 순서(=rank) + targetDate-1 의 base_date 로 mv_product_rank_weekly 에 저장 + * + * base_date 결정: + * batch 실행일이 targetDate 이고, MV 에는 targetDate-1(어제)을 base_date 로 적재한다. + * API 는 기본적으로 어제 날짜를 기준으로 랭킹을 조회하므로 일관성이 유지된다. + */ +@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"; + + /** + * 집계 상위 N 건. SQL LIMIT 과 chunk size 를 동일 상수로 묶어 + * 둘 중 하나만 변경되는 실수를 방지한다. + * + * LIMIT = TOP_N, chunk size = TOP_N 이 일치해야 결과 전체가 단일 Chunk 로 처리되고 + * WeeklyRankingItemWriter 의 rank 할당(1부터 순서대로)이 올바르게 동작한다. + */ + public static final int TOP_N = 100; + + /** + * 슬라이딩 윈도우 7일 집계 SQL. + * + * 일간 랭킹(RankingScoreCalculator)과 동일한 공식을 사용한다. + * score = LN(1 + SUM(view_count)) * weightView + * + LN(1 + SUM(like_count)) * weightLike + * + LN(1 + SUM(order_amount)) * weightOrder + * + * order_count 가 아닌 order_amount 를 사용하는 이유 (week9.md A-3 참고): + * 건수는 매출 기여를 무시하고, 금액 원본은 스케일 폭주로 view/like 를 압도한다. + * LN(1 + amount) 로 스케일을 압축하면 매출을 반영하면서 가중치 의미도 유지된다. + * + * 파라미터 순서: + * 1: weightView (view_count 가중치) + * 2: weightLike (like_count 가중치) + * 3: weightOrder (order_amount 가중치) + * 4: startTime (targetDate - 7일 00:00, 포함) + * 5: endTime (targetDate 00:00, 미포함) + */ + private static final String AGGREGATION_SQL = """ + SELECT + product_id, + LN(1 + SUM(view_count)) * ? + + LN(1 + SUM(like_count)) * ? + + LN(1 + SUM(order_amount)) * ? AS score + FROM product_metrics_hourly + WHERE bucket_hour >= ? AND bucket_hour < ? + GROUP BY product_id + ORDER BY score DESC + LIMIT %d + """.formatted(TOP_N); + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + private final WeeklyRankingItemWriter weeklyRankingItemWriter; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep()) + .listener(jobListener) + .build(); + } + + /** + * 주간 랭킹 Step. + * + * chunk size = TOP_N = LIMIT 이므로 쿼리 결과 전체가 단일 write() 호출로 처리된다. + * Writer 는 Chunk 순서를 기반으로 rank 를 1 부터 할당한다. + * + * @JobScope 를 적용하여 Job 실행마다 독립적인 Step 인스턴스를 생성한다. + */ + @JobScope + @Bean(STEP_NAME) + public Step weeklyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(TOP_N, transactionManager) + .reader(weeklyRankingReader(null, null, null)) + .writer(weeklyRankingItemWriter) + .listener(stepMonitorListener) + .build(); + } + + /** + * 주간 집계 JdbcCursorItemReader. + * + * @StepScope 로 Job 실행 시점에 targetDate 를 바인딩한다. + * null 로 선언된 파라미터는 @StepScope 프록시가 실제 Bean 을 주입하며, + * 컴파일 경고를 방지하기 위해 null 을 명시한다. + * + * JdbcCursorItemReader 는 서버 커서를 사용하여 한 Row 씩 스트리밍하므로 + * 대량의 집계 결과도 메모리 부하 없이 처리할 수 있다. + * + * @param dataSource @StepScope 에서 주입 + * @param rankingWeights @StepScope 에서 주입 + * @param targetDate JobParameter 에서 바인딩 + */ + @StepScope + @Bean("weeklyRankingReader") + public JdbcCursorItemReader weeklyRankingReader( + DataSource dataSource, + RankingWeights rankingWeights, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate + ) { + // 슬라이딩 윈도우: targetDate 기준 직전 7일 + LocalDateTime startTime = targetDate.minusDays(7).atStartOfDay(); + LocalDateTime endTime = targetDate.atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyRankingReader") + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .preparedStatementSetter(ps -> { + ps.setDouble(1, rankingWeights.view()); + ps.setDouble(2, rankingWeights.like()); + ps.setDouble(3, rankingWeights.order()); + ps.setObject(4, startTime); + ps.setObject(5, endTime); + }) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregate( + rs.getLong("product_id"), + rs.getDouble("score") + )) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java new file mode 100644 index 0000000000..9740ef288c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java @@ -0,0 +1,62 @@ +package com.loopers.batch.job.weekly.step; + +import com.loopers.batch.job.weekly.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.MvProductRankRow; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * 주간 랭킹 MV 테이블 Writer. + * + * Reader SQL 이 score DESC 로 정렬되어 있으므로 Chunk 의 순서가 곧 랭킹 순서다. + * 이 Writer 는 Chunk 순서를 기반으로 rank(1, 2, 3...) 를 부여하고 + * DELETE + INSERT 방식으로 mv_product_rank_weekly 를 갱신한다. + * + * baseDate 결정 규칙: + * MV 에는 "targetDate - 1일" 을 base_date 로 저장한다. + * 예: targetDate = 2026-04-10 → base_date = 2026-04-09 (어제) + * API 는 date 파라미터 생략 시 어제를 기준으로 조회하므로 일관성이 유지된다. + * + * @StepScope 로 선언하여 Job 실행마다 새 인스턴스를 생성하고 + * targetDate JobParameter 를 올바르게 바인딩한다. + */ +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class WeeklyRankingItemWriter implements ItemWriter { + + // JobParameter 에서 주입. targetDate - 1일이 MV 의 base_date 가 된다. + @Value("#{jobParameters['targetDate']}") + private LocalDate targetDate; + + private final MvProductRankRepository mvProductRankRepository; + + @Override + public void write(Chunk chunk) { + // API 는 어제를 기준으로 랭킹을 조회하므로 base_date = targetDate - 1 + LocalDate baseDate = targetDate.minusDays(1); + + List rows = new ArrayList<>(); + int rank = 1; + // SQL ORDER BY score DESC 가 이미 적용되어 있으므로 순서 = rank. + // WeeklyRankingJobConfig.TOP_N = chunk size = LIMIT 이 일치해야 단일 Chunk 가 보장된다. + // 다중 Chunk 가 되면 매 write() 호출마다 rank 가 1 부터 재시작되므로 반드시 단일 Chunk 를 유지할 것. + for (ProductMetricsAggregate item : chunk.getItems()) { + rows.add(new MvProductRankRow(item.productId(), rank++, item.score())); + } + + mvProductRankRepository.replaceWeeklyRanking(baseDate, rows); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java index 10b09b8fcc..b7216eba7f 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -14,8 +14,9 @@ public class ChunkListener { @AfterChunk void afterChunk(ChunkContext chunkContext) { log.info( - "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + - "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + "청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount() ); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java index cb5c8bebd7..4276fba54b 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -18,7 +18,7 @@ public class JobListener { @BeforeJob void beforeJob(JobExecution jobExecution) { - log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/config/RankingWeightsConfig.java b/apps/commerce-batch/src/main/java/com/loopers/config/RankingWeightsConfig.java new file mode 100644 index 0000000000..6d90f612d0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/config/RankingWeightsConfig.java @@ -0,0 +1,42 @@ +package com.loopers.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 랭킹 점수 가중치 설정. + * + * application.yml 의 ranking.weights 프리픽스에서 값을 바인딩한다. + * + * 점수 계산 공식: + * score = view_count * view + like_count * like + order_count * order + * + * 가중치는 합산이 1.0 일 필요는 없으나 모두 0 이어서는 안 된다. + * 일반적으로 구매 전환이 가장 중요하므로 order 가중치를 높게 설정한다. + */ +@Configuration +@EnableConfigurationProperties(RankingWeightsConfig.RankingWeights.class) +public class RankingWeightsConfig { + + /** + * 랭킹 점수 가중치 불변 VO. + * + * @param view 조회수 가중치 (기본값 0.1) + * @param like 좋아요수 가중치 (기본값 0.2) + * @param order 주문수 가중치 (기본값 0.7) + */ + @ConfigurationProperties(prefix = "ranking.weights") + public record RankingWeights(double view, double like, double order) { + public RankingWeights { + // 음수 가중치는 점수를 역전시키므로 허용하지 않는다 + if (view < 0 || like < 0 || order < 0) { + throw new IllegalArgumentException("ranking.weights must be non-negative"); + } + // 모든 가중치가 0 이면 점수가 항상 0 이어서 랭킹 정렬이 의미 없다 + if (view + like + order <= 0) { + throw new IllegalArgumentException("ranking.weights sum must be positive"); + } + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java new file mode 100644 index 0000000000..d0e4fc0d9c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * MV 랭킹 테이블 쓰기 전용 저장소 인터페이스 (DIP). + * + * 배치 Writer 는 이 인터페이스에만 의존하며, JDBC 구현 세부 사항을 알지 못한다. + */ +public interface MvProductRankRepository { + + /** + * 주간 MV 테이블을 해당 baseDate 기준으로 교체한다 (DELETE + INSERT). + * + * @param baseDate 기준일 (targetDate - 1일) + * @param rows rank 1 부터 순서대로 정렬된 집계 결과 + */ + void replaceWeeklyRanking(LocalDate baseDate, List rows); + + /** + * 월간 MV 테이블을 해당 baseDate 기준으로 교체한다 (DELETE + INSERT). + * + * @param baseDate 기준일 (targetDate - 1일) + * @param rows rank 1 부터 순서대로 정렬된 집계 결과 + */ + void replaceMonthlyRanking(LocalDate baseDate, List rows); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRow.java new file mode 100644 index 0000000000..2559907eb4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRow.java @@ -0,0 +1,21 @@ +package com.loopers.domain.ranking; + +/** + * MV 랭킹 테이블 적재 단위 VO. + * + * ItemWriter 가 Chunk 의 순서를 기반으로 rank 를 1부터 순차 할당한 뒤 생성한다. + * Reader SQL 이 이미 score 내림차순으로 정렬되어 있으므로, + * Chunk 순서(0, 1, 2 ...) = rank(1, 2, 3 ...) 가 보장된다. + * + * mv_product_rank_weekly 와 mv_product_rank_monthly 양쪽에 공통으로 사용된다. + * + * @param productId 상품 ID + * @param rank 1-based 랭킹 순위 + * @param score 집계 기간 내 가중치 합산 점수 + */ +public record MvProductRankRow( + Long productId, + int rank, + double score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregate.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregate.java new file mode 100644 index 0000000000..d06809274a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductMetricsAggregate.java @@ -0,0 +1,25 @@ +package com.loopers.domain.ranking; + +/** + * product_metrics_hourly 집계 쿼리 결과 VO. + * + * JdbcCursorItemReader 의 읽기 단위(Item)이다. + * SQL 레벨에서 슬라이딩 윈도우 기간의 집계와 가중치 점수 계산이 이미 완료되어 있으므로 + * 이 VO 는 단순히 그 결과를 담는 역할만 한다. + * + * SQL 점수 계산 공식 (일간 랭킹 RankingScoreCalculator 와 동일): + * LN(1 + SUM(view_count)) * weightView + * + LN(1 + SUM(like_count)) * weightLike + * + LN(1 + SUM(order_amount)) * weightOrder + * + * Reader 는 score 내림차순으로 결과를 반환하므로, + * Chunk 내 순서가 곧 랭킹 순서를 의미한다. + * + * @param productId 상품 ID + * @param score 슬라이딩 윈도우 기간 내 가중치 합산 점수 + */ +public record ProductMetricsAggregate( + Long productId, + double score +) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java new file mode 100644 index 0000000000..987d491297 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java @@ -0,0 +1,79 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.MvProductRankRow; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * MvProductRankRepository JDBC 구현체. + * + * DELETE + INSERT 전략으로 MV 테이블을 갱신한다. + * 각 replace 메서드에 @Transactional 을 선언하여 DELETE 와 INSERT 의 원자성을 + * 호출 컨텍스트에 관계없이 Repository 스스로 보장한다. + * Spring Batch Chunk 트랜잭션 안에서 호출될 때는 REQUIRED 전파로 기존 트랜잭션에 합류한다. + * + * UPSERT(INSERT ON DUPLICATE KEY UPDATE) 대신 DELETE + INSERT 를 선택한 이유: + * - 같은 base_date 의 데이터를 완전히 교체하므로 잔여 행이 남지 않는다. + * - 최대 100건(LIMIT 100) 의 소규모 배치이므로 락 경합이 문제되지 않는다. + * - 코드가 단순하고 멱등성이 보장된다. + * + * 같은 base_date 로 재실행하면 기존 데이터를 삭제하고 새 랭킹으로 덮어쓴다. + */ +@Repository +@RequiredArgsConstructor +public class JdbcMvProductRankRepository implements MvProductRankRepository { + + private static final String DELETE_WEEKLY = + "DELETE FROM mv_product_rank_weekly WHERE base_date = ?"; + + private static final String INSERT_WEEKLY = + "INSERT INTO mv_product_rank_weekly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)"; + + private static final String DELETE_MONTHLY = + "DELETE FROM mv_product_rank_monthly WHERE base_date = ?"; + + private static final String INSERT_MONTHLY = + "INSERT INTO mv_product_rank_monthly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)"; + + private final JdbcTemplate jdbcTemplate; + + @Transactional + @Override + public void replaceWeeklyRanking(LocalDate baseDate, List rows) { + // 해당 base_date 의 기존 랭킹 전체 삭제 후 새 데이터 삽입 + jdbcTemplate.update(DELETE_WEEKLY, baseDate); + insertBatch(INSERT_WEEKLY, baseDate, rows); + } + + @Transactional + @Override + public void replaceMonthlyRanking(LocalDate baseDate, List rows) { + jdbcTemplate.update(DELETE_MONTHLY, baseDate); + insertBatch(INSERT_MONTHLY, baseDate, rows); + } + + /** + * rows 를 JDBC batchUpdate 로 한 번에 삽입한다. + * + * updated_at 은 배치 실행 시점 기준으로 일괄 설정한다. + * rows 가 비어 있으면 batchUpdate 를 호출하지 않아도 되지만 + * JdbcTemplate 이 내부에서 빈 배치를 건너뛰므로 별도 처리가 필요 없다. + */ + private void insertBatch(String sql, LocalDate baseDate, List rows) { + LocalDateTime now = LocalDateTime.now(); + jdbcTemplate.batchUpdate(sql, rows, rows.size(), (ps, row) -> { + ps.setLong(1, row.productId()); + ps.setObject(2, baseDate); + ps.setInt(3, row.rank()); + ps.setDouble(4, row.score()); + ps.setObject(5, now); + }); + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index 9aa0d760a6..ad8f2b86fd 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -22,6 +22,12 @@ management: defaults: enabled: false +ranking: + weights: + view: 0.1 + like: 0.2 + order: 0.7 + --- spring: config: diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java new file mode 100644 index 0000000000..154d99ee61 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java @@ -0,0 +1,250 @@ +package com.loopers.job.monthly; + +import com.loopers.batch.job.monthly.MonthlyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + MonthlyRankingJobConfig.JOB_NAME) +class MonthlyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(MonthlyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics_hourly"); + } + + @DisplayName("targetDate 파라미터 없이 실행하면 배치가 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // given + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다.") + @Test + void completedWithEmptyMetrics() throws Exception { + // given + jobLauncherTestUtils.setJob(job); + LocalDate targetDate = LocalDate.of(2026, 4, 10); + + // when + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countByBaseDate("mv_product_rank_monthly", targetDate.minusDays(1))).isZero() + ); + } + + @DisplayName("집계 대상 데이터가 있으면 MV 테이블에 score 내림차순으로 랭킹이 적재된다.") + @Test + void populatesMvTableWithRanking() throws Exception { + // given — score 공식: LN(1+view)*0.1 + LN(1+like)*0.2 + LN(1+orderAmount)*0.7 + LocalDate targetDate = LocalDate.of(2026, 4, 11); + LocalDateTime bucket = targetDate.minusDays(10).atTime(12, 0); // 30일 윈도우 내 + insertMetrics(1L, bucket, 0, 0, 0, 10000.0); // LN(10001)*0.7 ≈ 6.45 → rank 1 + insertMetrics(2L, bucket, 30, 0, 0, 0.0); // LN(31)*0.1 ≈ 0.34 → rank 2 + jobLauncherTestUtils.setJob(job); + + // when + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank`, score FROM mv_product_rank_monthly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(1L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1), + () -> assertThat(((Number) rows.get(1).get("product_id")).longValue()).isEqualTo(2L), + () -> assertThat(((Number) rows.get(1).get("rank")).intValue()).isEqualTo(2) + ); + } + + @DisplayName("재실행 시 기존 MV 데이터가 새 랭킹으로 교체된다.") + @Test + void replacesExistingMvOnRerun() throws Exception { + // given — 1차 실행 완료: product 1만 적재된 상태 + LocalDate targetDate = LocalDate.of(2026, 4, 12); + LocalDateTime bucket = targetDate.minusDays(10).atTime(12, 0); + insertMetrics(1L, bucket, 0, 0, 0, 5000.0); // LN(5001)*0.7 ≈ 5.95 + jobLauncherTestUtils.setJob(job); + jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // when — product 2 추가 후 동일 targetDate 로 재실행 (run.id 로 새 JobInstance 생성) + insertMetrics(2L, bucket, 0, 0, 0, 10000.0); // LN(10001)*0.7 ≈ 6.45 + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 2L) + .toJobParameters()); + + // then — product 2 가 order_amount 더 높아서 rank 1 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_monthly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(2L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1) + ); + } + + @DisplayName("슬라이딩 윈도우 바깥 데이터는 집계에서 제외된다.") + @Test + void excludesDataOutsideWindow() throws Exception { + // given — 윈도우: [targetDate-30일 00:00, targetDate 00:00) + LocalDate targetDate = LocalDate.of(2026, 4, 20); + LocalDateTime outsideWindow = targetDate.minusDays(31).atTime(12, 0); // 윈도우 시작보다 하루 이전 + LocalDateTime insideWindow = targetDate.minusDays(29).atTime(12, 0); // 윈도우 내 + insertMetrics(1L, outsideWindow, 0, 0, 0, 50000.0); // 집계 제외 + insertMetrics(2L, insideWindow, 0, 0, 0, 1000.0); // 집계 포함 + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — product 2만 MV 에 적재되어야 한다 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_monthly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(1), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(2L) + ); + } + + @DisplayName("동일 상품의 여러 bucket_hour 데이터가 합산되어 점수가 계산된다.") + @Test + void aggregatesMultipleBucketHoursForSameProduct() throws Exception { + // given — product 1: 두 시간대 합산 order_amount=10000, product 2: 단일 order_amount=9000 + LocalDate targetDate = LocalDate.of(2026, 4, 21); + LocalDateTime bucket1 = targetDate.minusDays(10).atTime(10, 0); + LocalDateTime bucket2 = targetDate.minusDays(10).atTime(11, 0); + insertMetrics(1L, bucket1, 0, 0, 0, 5000.0); + insertMetrics(1L, bucket2, 0, 0, 0, 5000.0); // 합산 10000 → LN(10001)*0.7 ≈ 6.45 + insertMetrics(2L, bucket1, 0, 0, 0, 9000.0); // 단일 9000 → LN(9001)*0.7 ≈ 6.28 + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — product 1이 합산 score 가 높아 rank 1 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_monthly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(1L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1) + ); + } + + @DisplayName("집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다.") + @Test + void limitsToTop100() throws Exception { + // given — 101개 상품 데이터 삽입 (score 차별화: 각 상품마다 다른 order_amount) + LocalDate targetDate = LocalDate.of(2026, 4, 22); + LocalDateTime bucket = targetDate.minusDays(10).atTime(12, 0); + for (long i = 1; i <= 101; i++) { + insertMetrics(i, bucket, 0, 0, 0, (102 - i) * 100.0); + } + jobLauncherTestUtils.setJob(job); + + // when + jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — MV 에는 정확히 100건만 존재 + LocalDate baseDate = targetDate.minusDays(1); + int count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_monthly WHERE base_date = ?", + Integer.class, baseDate + ); + assertThat(count).isEqualTo(100); + } + + private void insertMetrics(Long productId, LocalDateTime bucketHour, + long viewCount, long likeCount, long orderCount, double orderAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics_hourly (product_id, bucket_hour, view_count, like_count, order_count, order_amount, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId, bucketHour, viewCount, likeCount, orderCount, orderAmount, LocalDateTime.now() + ); + } + + private int countByBaseDate(String table, LocalDate baseDate) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM `" + table + "` WHERE base_date = ?", + Integer.class, baseDate + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..ce497d1f79 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java @@ -0,0 +1,250 @@ +package com.loopers.job.weekly; + +import com.loopers.batch.job.weekly.WeeklyRankingJobConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private Job job; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics_hourly"); + } + + @DisplayName("targetDate 파라미터 없이 실행하면 배치가 실패한다.") + @Test + void failsWithoutTargetDate() throws Exception { + // given + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()); + } + + @DisplayName("집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다.") + @Test + void completedWithEmptyMetrics() throws Exception { + // given + jobLauncherTestUtils.setJob(job); + LocalDate targetDate = LocalDate.of(2026, 4, 10); + + // when + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countByBaseDate("mv_product_rank_weekly", targetDate.minusDays(1))).isZero() + ); + } + + @DisplayName("집계 대상 데이터가 있으면 MV 테이블에 score 내림차순으로 랭킹이 적재된다.") + @Test + void populatesMvTableWithRanking() throws Exception { + // given — score 공식: LN(1+view)*0.1 + LN(1+like)*0.2 + LN(1+orderAmount)*0.7 + LocalDate targetDate = LocalDate.of(2026, 4, 11); + LocalDateTime bucket = targetDate.minusDays(3).atTime(12, 0); // 윈도우 내 + insertMetrics(1L, bucket, 0, 0, 0, 10000.0); // LN(10001)*0.7 ≈ 6.45 → rank 1 + insertMetrics(2L, bucket, 30, 0, 0, 0.0); // LN(31)*0.1 ≈ 0.34 → rank 2 + jobLauncherTestUtils.setJob(job); + + // when + var jobParameters = new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank`, score FROM mv_product_rank_weekly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(1L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1), + () -> assertThat(((Number) rows.get(1).get("product_id")).longValue()).isEqualTo(2L), + () -> assertThat(((Number) rows.get(1).get("rank")).intValue()).isEqualTo(2) + ); + } + + @DisplayName("재실행 시 기존 MV 데이터가 새 랭킹으로 교체된다.") + @Test + void replacesExistingMvOnRerun() throws Exception { + // given — 1차 실행 완료: product 1만 적재된 상태 + LocalDate targetDate = LocalDate.of(2026, 4, 12); + LocalDateTime bucket = targetDate.minusDays(3).atTime(12, 0); + insertMetrics(1L, bucket, 0, 0, 0, 5000.0); // LN(5001)*0.7 ≈ 5.95 + jobLauncherTestUtils.setJob(job); + jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // when — product 2 추가 후 동일 targetDate 로 재실행 (run.id 로 새 JobInstance 생성) + insertMetrics(2L, bucket, 0, 0, 0, 10000.0); // LN(10001)*0.7 ≈ 6.45 + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .addLong("run.id", 2L) + .toJobParameters()); + + // then — product 2 가 order_amount 더 높아서 rank 1 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_weekly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(2L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1) + ); + } + + @DisplayName("슬라이딩 윈도우 바깥 데이터는 집계에서 제외된다.") + @Test + void excludesDataOutsideWindow() throws Exception { + // given — 윈도우: [targetDate-7일 00:00, targetDate 00:00) + LocalDate targetDate = LocalDate.of(2026, 4, 20); + LocalDateTime outsideWindow = targetDate.minusDays(8).atTime(12, 0); // 윈도우 시작보다 하루 이전 + LocalDateTime insideWindow = targetDate.minusDays(6).atTime(12, 0); // 윈도우 내 + insertMetrics(1L, outsideWindow, 0, 0, 0, 50000.0); // 집계 제외 + insertMetrics(2L, insideWindow, 0, 0, 0, 1000.0); // 집계 포함 + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — product 2만 MV 에 적재되어야 한다 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_weekly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(1), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(2L) + ); + } + + @DisplayName("동일 상품의 여러 bucket_hour 데이터가 합산되어 점수가 계산된다.") + @Test + void aggregatesMultipleBucketHoursForSameProduct() throws Exception { + // given — product 1: 두 시간대 합산 order_amount=10000, product 2: 단일 order_amount=9000 + LocalDate targetDate = LocalDate.of(2026, 4, 21); + LocalDateTime bucket1 = targetDate.minusDays(3).atTime(10, 0); + LocalDateTime bucket2 = targetDate.minusDays(3).atTime(11, 0); + insertMetrics(1L, bucket1, 0, 0, 0, 5000.0); + insertMetrics(1L, bucket2, 0, 0, 0, 5000.0); // 합산 10000 → LN(10001)*0.7 ≈ 6.45 + insertMetrics(2L, bucket1, 0, 0, 0, 9000.0); // 단일 9000 → LN(9001)*0.7 ≈ 6.28 + jobLauncherTestUtils.setJob(job); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — product 1이 합산 score 가 높아 rank 1 + LocalDate baseDate = targetDate.minusDays(1); + List> rows = jdbcTemplate.queryForList( + "SELECT product_id, `rank` FROM mv_product_rank_weekly WHERE base_date = ? ORDER BY `rank`", + baseDate + ); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(rows).hasSize(2), + () -> assertThat(((Number) rows.get(0).get("product_id")).longValue()).isEqualTo(1L), + () -> assertThat(((Number) rows.get(0).get("rank")).intValue()).isEqualTo(1) + ); + } + + @DisplayName("집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다.") + @Test + void limitsToTop100() throws Exception { + // given — 101개 상품 데이터 삽입 (score 차별화: 각 상품마다 다른 order_amount) + LocalDate targetDate = LocalDate.of(2026, 4, 22); + LocalDateTime bucket = targetDate.minusDays(3).atTime(12, 0); + for (long i = 1; i <= 101; i++) { + insertMetrics(i, bucket, 0, 0, 0, (102 - i) * 100.0); + } + jobLauncherTestUtils.setJob(job); + + // when + jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — MV 에는 정확히 100건만 존재 + LocalDate baseDate = targetDate.minusDays(1); + int count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_weekly WHERE base_date = ?", + Integer.class, baseDate + ); + assertThat(count).isEqualTo(100); + } + + private void insertMetrics(Long productId, LocalDateTime bucketHour, + long viewCount, long likeCount, long orderCount, double orderAmount) { + jdbcTemplate.update( + "INSERT INTO product_metrics_hourly (product_id, bucket_hour, view_count, like_count, order_count, order_amount, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + productId, bucketHour, viewCount, likeCount, orderCount, orderAmount, LocalDateTime.now() + ); + } + + private int countByBaseDate(String table, LocalDate baseDate) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM `" + table + "` WHERE base_date = ?", + Integer.class, baseDate + ); + } +} diff --git a/apps/commerce-batch/src/test/resources/application.yml b/apps/commerce-batch/src/test/resources/application.yml index cbed2619e2..20c9956066 100644 --- a/apps/commerce-batch/src/test/resources/application.yml +++ b/apps/commerce-batch/src/test/resources/application.yml @@ -4,3 +4,15 @@ spring: enabled: false jdbc: initialize-schema: always + sql: + init: + mode: always + jpa: + defer-datasource-initialization: true + +ranking: + weights: + view: 0.1 + like: 0.2 + order: 0.7 + diff --git a/scripts/run-monthly-ranking.sh b/scripts/run-monthly-ranking.sh new file mode 100755 index 0000000000..383d8afbfc --- /dev/null +++ b/scripts/run-monthly-ranking.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# run-monthly-ranking.sh +# 월간 랭킹 배치 실행 스크립트 +# +# 사용법: +# ./scripts/run-monthly-ranking.sh [targetDate] +# +# targetDate 미입력 시 오늘 날짜(today)를 기본값으로 사용한다. +# 슬라이딩 윈도우는 [targetDate-30, targetDate) 이므로 +# 매일 자정 직후 실행하면 어제까지의 30일 데이터를 집계한다. +# +# Jenkins Pipeline 예시: +# stage('Monthly Ranking Batch') { +# steps { +# sh './scripts/run-monthly-ranking.sh' +# } +# } +# +# Cron 예시 (매일 새벽 2시 30분): +# 30 2 * * * /app/scripts/run-monthly-ranking.sh >> /var/log/batch/monthly-ranking.log 2>&1 + +set -euo pipefail + +JAR_PATH="${JAR_PATH:-apps/commerce-batch/build/libs/commerce-batch.jar}" +SPRING_PROFILE="${SPRING_PROFILE:-prd}" +TARGET_DATE="${1:-$(date +%Y-%m-%d)}" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting monthlyRankingJob | targetDate=${TARGET_DATE}" + +java -jar "${JAR_PATH}" \ + --spring.profiles.active="${SPRING_PROFILE}" \ + --job.name=monthlyRankingJob \ + targetDate="${TARGET_DATE}" + +EXIT_CODE=$? + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] monthlyRankingJob finished | exitCode=${EXIT_CODE}" +exit ${EXIT_CODE} diff --git a/scripts/run-weekly-ranking.sh b/scripts/run-weekly-ranking.sh new file mode 100755 index 0000000000..bcd696aba2 --- /dev/null +++ b/scripts/run-weekly-ranking.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# run-weekly-ranking.sh +# 주간 랭킹 배치 실행 스크립트 +# +# 사용법: +# ./scripts/run-weekly-ranking.sh [targetDate] +# +# targetDate 미입력 시 오늘 날짜(today)를 기본값으로 사용한다. +# 슬라이딩 윈도우는 [targetDate-7, targetDate) 이므로 +# 매일 자정 직후 실행하면 어제까지의 7일 데이터를 집계한다. +# +# Jenkins Pipeline 예시: +# stage('Weekly Ranking Batch') { +# steps { +# sh './scripts/run-weekly-ranking.sh' +# } +# } +# +# Cron 예시 (매일 새벽 2시): +# 0 2 * * * /app/scripts/run-weekly-ranking.sh >> /var/log/batch/weekly-ranking.log 2>&1 + +set -euo pipefail + +JAR_PATH="${JAR_PATH:-apps/commerce-batch/build/libs/commerce-batch.jar}" +SPRING_PROFILE="${SPRING_PROFILE:-prd}" +TARGET_DATE="${1:-$(date +%Y-%m-%d)}" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting weeklyRankingJob | targetDate=${TARGET_DATE}" + +java -jar "${JAR_PATH}" \ + --spring.profiles.active="${SPRING_PROFILE}" \ + --job.name=weeklyRankingJob \ + targetDate="${TARGET_DATE}" + +EXIT_CODE=$? + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] weeklyRankingJob finished | exitCode=${EXIT_CODE}" +exit ${EXIT_CODE} From 64a399ab5e655c8d98486ada9a4d1c1419a88ef4 Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Thu, 16 Apr 2026 23:14:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20mv=5Fproduct=5Frank=5Fweekly/monthl?= =?UTF-8?q?y=20MV=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeeklyRankingRepository, MonthlyRankingRepository: 도메인 인터페이스 정의 (DIP) - MvProductRankWeekly, MvProductRankMonthly: JPA 엔티티 (복합 PK MvProductRankId) - rank 예약어 충돌 방지를 위해 @Column(name = "`rank`") 백틱 인용 적용 - WeeklyRankingRepositoryImpl, MonthlyRankingRepositoryImpl: JPA 기반 구현체 Co-Authored-By: Claude Sonnet 4.6 --- .../ranking/MonthlyRankingRepository.java | 28 ++++++++++ .../ranking/WeeklyRankingRepository.java | 28 ++++++++++ .../ranking/MonthlyRankingRepositoryImpl.java | 46 +++++++++++++++++ .../ranking/MvProductRankId.java | 43 ++++++++++++++++ .../ranking/MvProductRankMonthly.java | 49 ++++++++++++++++++ .../MvProductRankMonthlyJpaRepository.java | 30 +++++++++++ .../ranking/MvProductRankWeekly.java | 51 +++++++++++++++++++ .../MvProductRankWeeklyJpaRepository.java | 30 +++++++++++ .../ranking/WeeklyRankingRepositoryImpl.java | 46 +++++++++++++++++ 9 files changed, 351 insertions(+) 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/WeeklyRankingRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java 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..e104890a09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 월간 MV 랭킹 읽기 전용 저장소 인터페이스 (DIP). + * + * commerce-batch 가 mv_product_rank_monthly 에 적재한 데이터를 조회한다. + * base_date = batch 실행일 - 1일 (어제 기준 직전 30일 집계 결과). + */ +public interface MonthlyRankingRepository { + + /** + * 지정 baseDate 의 월간 TOP-N 을 반환한다. rank 는 1-based. + * + * @param baseDate 집계 기준일 (MV 테이블의 base_date) + * @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based) + * @param size 페이지 크기 + * @return 해당 날짜의 월간 랭킹 엔트리. 데이터가 없으면 빈 리스트. + */ + List getTopN(LocalDate baseDate, int pageOneBased, int size); + + /** + * 지정 baseDate 의 월간 랭킹 전체 엔트리 수. + */ + long getTotal(LocalDate baseDate); +} 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..753ab475ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 주간 MV 랭킹 읽기 전용 저장소 인터페이스 (DIP). + * + * commerce-batch 가 mv_product_rank_weekly 에 적재한 데이터를 조회한다. + * base_date = batch 실행일 - 1일 (어제 기준 직전 7일 집계 결과). + */ +public interface WeeklyRankingRepository { + + /** + * 지정 baseDate 의 주간 TOP-N 을 반환한다. rank 는 1-based. + * + * @param baseDate 집계 기준일 (MV 테이블의 base_date) + * @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based) + * @param size 페이지 크기 + * @return 해당 날짜의 주간 랭킹 엔트리. 데이터가 없으면 빈 리스트. + */ + List getTopN(LocalDate baseDate, int pageOneBased, int size); + + /** + * 지정 baseDate 의 주간 랭킹 전체 엔트리 수. + */ + long getTotal(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java new file mode 100644 index 0000000000..3281921c36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +/** + * MonthlyRankingRepository JPA 구현체. + * + * WeeklyRankingRepositoryImpl 과 구조가 동일하며, 대상 JPA 레포지토리만 다르다. + * MvProductRankMonthlyJpaRepository 를 감싸서 JPA 엔티티를 RankingEntry 로 변환한다. + */ +@RequiredArgsConstructor +@Component +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MvProductRankMonthlyJpaRepository jpaRepository; + + /** + * pageOneBased 를 JPA 의 0-based 페이지로 변환하여 조회한다. + * baseDate 가 null 이거나 size 가 0 이하이면 빈 리스트를 반환한다. + */ + @Override + public List getTopN(LocalDate baseDate, int pageOneBased, int size) { + if (baseDate == null || size <= 0) return Collections.emptyList(); + // API 는 1-based 페이지를 사용하므로 JPA PageRequest 에는 (page-1)을 전달 + int page = Math.max(pageOneBased, 1); + List entities = jpaRepository.findByBaseDateOrderByRankAsc( + baseDate, PageRequest.of(page - 1, size)); + return entities.stream() + .map(e -> new RankingEntry(e.getProductId(), e.getRank(), e.getScore())) + .toList(); + } + + @Override + public long getTotal(LocalDate baseDate) { + if (baseDate == null) return 0L; + return jpaRepository.countByBaseDate(baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankId.java new file mode 100644 index 0000000000..852b1d3e75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankId.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.ranking; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +/** + * mv_product_rank_weekly / mv_product_rank_monthly 복합 PK 클래스. + * + * JPA @IdClass 사양 요구사항: + * - Serializable 구현 + * - 인수 없는 기본 생성자 (JPA 리플렉션 인스턴스화) + * - equals / hashCode 재정의 (동등성 비교) + * + * (product_id, base_date) 조합이 PK 이므로 동일 상품의 날짜별 랭킹을 각각 저장할 수 있다. + * 이를 통해 과거 날짜의 랭킹 조회(이력 조회)가 가능하다. + */ +public class MvProductRankId implements Serializable { + + private Long productId; + private LocalDate baseDate; + + // JPA 스펙: 인수 없는 기본 생성자 필수 + protected MvProductRankId() { + } + + public MvProductRankId(Long productId, LocalDate baseDate) { + this.productId = productId; + this.baseDate = baseDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MvProductRankId that)) return false; + return Objects.equals(productId, that.productId) && Objects.equals(baseDate, that.baseDate); + } + + @Override + public int hashCode() { + return Objects.hash(productId, baseDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java new file mode 100644 index 0000000000..fe1e088064 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthly.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 월간 랭킹 Materialized View JPA 엔티티 (읽기 전용). + * + * MvProductRankWeekly 와 구조가 동일하며, 집계 대상 기간(30일)과 테이블명만 다르다. + * + * commerce-batch 의 monthlyRankingJob 이 JDBC 로 직접 적재하며, + * commerce-api 는 조회(읽기)만 수행한다. + * + * base_date = batch 실행일 - 1일 (어제 기준 직전 30일 슬라이딩 윈도우 집계) + */ +@Entity +@Table(name = "mv_product_rank_monthly") +@IdClass(MvProductRankId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankMonthly { + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Id + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + // rank 는 MySQL 8 예약어이므로 백틱으로 인용 + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..e00f8e7512 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +/** + * mv_product_rank_monthly Spring Data JPA 레포지토리. + * + * MvProductRankWeeklyJpaRepository 와 구조가 동일하며, 대상 엔티티만 다르다. + * MonthlyRankingRepositoryImpl 에서만 사용하며 도메인 레이어에 직접 노출하지 않는다 (DIP). + */ +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + /** + * 지정 기준일의 월간 랭킹을 rank 오름차순(1위부터)으로 페이지 조회한다. + * + * @param baseDate 집계 기준일 (MV base_date 컬럼) + * @param pageable 페이지 정보 (PageRequest.of(page-1, size)) + */ + List findByBaseDateOrderByRankAsc(LocalDate baseDate, Pageable pageable); + + /** + * 지정 기준일의 전체 랭킹 엔트리 수를 반환한다. + * API 페이지네이션의 totalElements 산정에 사용한다. + */ + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java new file mode 100644 index 0000000000..dfae6c98a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeekly.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.ranking; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 주간 랭킹 Materialized View JPA 엔티티 (읽기 전용). + * + * 이 엔티티는 commerce-batch 의 weeklyRankingJob 이 JDBC 로 직접 적재하며, + * commerce-api 는 조회(읽기)만 수행한다. 별도 저장 메서드를 제공하지 않는다. + * + * 테이블 구조: + * PRIMARY KEY (product_id, base_date) — 날짜별 랭킹 이력을 보존한다. + * base_date = batch 실행일 - 1일 (어제 기준 직전 7일 슬라이딩 윈도우 집계) + * + * rank 컬럼이 MySQL 8 의 예약어이므로 @Column(name = "`rank`") 로 백틱 인용을 적용한다. + */ +@Entity +@Table(name = "mv_product_rank_weekly") +@IdClass(MvProductRankId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MvProductRankWeekly { + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Id + @Column(name = "base_date", nullable = false) + private LocalDate baseDate; + + // rank 는 MySQL 8 예약어이므로 백틱으로 인용 + @Column(name = "`rank`", nullable = false) + private int rank; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..984d96b3df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.ranking; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +/** + * mv_product_rank_weekly Spring Data JPA 레포지토리. + * + * 파생 쿼리(Derived Query)를 사용하므로 별도 @Query 없이 메서드 이름으로 SQL 을 생성한다. + * WeeklyRankingRepositoryImpl 에서만 사용하며 도메인 레이어에 직접 노출하지 않는다 (DIP). + */ +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + /** + * 지정 기준일의 주간 랭킹을 rank 오름차순(1위부터)으로 페이지 조회한다. + * + * @param baseDate 집계 기준일 (MV base_date 컬럼) + * @param pageable 페이지 정보 (PageRequest.of(page-1, size)) + */ + List findByBaseDateOrderByRankAsc(LocalDate baseDate, Pageable pageable); + + /** + * 지정 기준일의 전체 랭킹 엔트리 수를 반환한다. + * API 페이지네이션의 totalElements 산정에 사용한다. + */ + long countByBaseDate(LocalDate baseDate); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java new file mode 100644 index 0000000000..3ceed2a37c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; + +/** + * WeeklyRankingRepository JPA 구현체. + * + * MvProductRankWeeklyJpaRepository 를 감싸서 JPA 엔티티를 도메인 VO(RankingEntry) 로 변환한다. + * 도메인 레이어는 이 구현체를 직접 알지 못하고 WeeklyRankingRepository 인터페이스에만 의존한다 (DIP). + */ +@RequiredArgsConstructor +@Component +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final MvProductRankWeeklyJpaRepository jpaRepository; + + /** + * pageOneBased 를 JPA 의 0-based 페이지로 변환하여 조회한다. + * baseDate 가 null 이거나 size 가 0 이하이면 빈 리스트를 반환한다. + */ + @Override + public List getTopN(LocalDate baseDate, int pageOneBased, int size) { + if (baseDate == null || size <= 0) return Collections.emptyList(); + // API 는 1-based 페이지를 사용하므로 JPA PageRequest 에는 (page-1)을 전달 + int page = Math.max(pageOneBased, 1); + List entities = jpaRepository.findByBaseDateOrderByRankAsc( + baseDate, PageRequest.of(page - 1, size)); + return entities.stream() + .map(e -> new RankingEntry(e.getProductId(), e.getRank(), e.getScore())) + .toList(); + } + + @Override + public long getTotal(LocalDate baseDate) { + if (baseDate == null) return 0L; + return jpaRepository.countByBaseDate(baseDate); + } +} From 1282c89591353528fb48e9802fcda3fdc72f3b2d Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Thu, 16 Apr 2026 23:14:36 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20API=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20RankingAssembler=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WeeklyRankingFacade, MonthlyRankingFacade: MV 테이블 조회 후 RankingAssembler 에 위임 - date null 시 KST 기준 어제 날짜 기본값 (배치 적재 기준일과 일치) - RankingAssembler: entries → 가시성 필터 → RankingPageResult 조립 로직 단일화 - 일간/주간/월간 Facade 의 Shotgun Surgery 방지 - RankingPageResult: effectiveDate + total + items 를 Facade 에서 하나로 묶어 반환 - Controller 가 Facade 내부 메서드를 직접 호출하는 구조 제거 - RankingPageQuery: 세 Controller 공통 파라미터 파싱·보정 로직 단일화 - GET /api/v1/rankings/weekly, /monthly 엔드포인트 추가 (별도 엔드포인트 방식 채택) - RankingAssemblerTest, WeeklyRankingFacadeTest, MonthlyRankingFacadeTest 작성 - WeeklyRankingV1ControllerTest, MonthlyRankingV1ControllerTest, E2E 테스트 작성 Co-Authored-By: Claude Sonnet 4.6 --- .../ranking/MonthlyRankingFacade.java | 44 ++++ .../application/ranking/RankingAssembler.java | 50 ++++ .../ranking/RankingPageResult.java | 21 ++ .../ranking/WeeklyRankingFacade.java | 43 ++++ .../ranking/MonthlyRankingV1Controller.java | 54 ++++ .../api/ranking/RankingPageQuery.java | 51 ++++ .../ranking/WeeklyRankingV1Controller.java | 55 ++++ .../ranking/MonthlyRankingFacadeTest.java | 83 ++++++ .../ranking/RankingAssemblerTest.java | 106 ++++++++ .../ranking/WeeklyRankingFacadeTest.java | 83 ++++++ .../api/MonthlyRankingV1ApiE2ETest.java | 240 ++++++++++++++++++ .../api/WeeklyRankingV1ApiE2ETest.java | 240 ++++++++++++++++++ .../MonthlyRankingV1ControllerTest.java | 164 ++++++++++++ .../WeeklyRankingV1ControllerTest.java | 164 ++++++++++++ 14 files changed, 1398 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingPageQuery.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/MonthlyRankingFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/WeeklyRankingFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/MonthlyRankingV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/WeeklyRankingV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1ControllerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1ControllerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingFacade.java new file mode 100644 index 0000000000..f07e4a2802 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingFacade.java @@ -0,0 +1,44 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +/** + * 월간 랭킹 Use Case Facade. + * + * WeeklyRankingFacade 와 처리 흐름이 동일하며, 조회 대상 저장소만 다르다. + * MV 테이블 조회 후 상품 가시성 필터링과 RankingItemInfo 조립을 RankingAssembler 에 위임한다. + * + * date 가 null 이면 KST 기준 어제 날짜를 기본값으로 사용한다. + * batch 가 targetDate - 1일을 base_date 로 적재하므로 최신 월간 랭킹의 기본 기준일은 어제다. + */ +@Component +@RequiredArgsConstructor +public class MonthlyRankingFacade { + + private final MonthlyRankingRepository monthlyRankingRepository; + private final RankingAssembler rankingAssembler; + private final Clock clock; + + /** + * 월간 랭킹 목록을 조회하고 상품 정보를 합산하여 반환한다. + * + * @param date 조회 기준일. null 이면 KST 어제 날짜를 사용한다. + * @param pageOneBased 1-based 페이지 번호 + * @param size 페이지 크기 + * @return 가시성 필터링 후 RankingItemInfo 목록과 페이지 메타데이터. + */ + public RankingPageResult getMonthlyRanking(LocalDate date, int pageOneBased, int size) { + LocalDate baseDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)).minusDays(1); + + long total = monthlyRankingRepository.getTotal(baseDate); + List entries = monthlyRankingRepository.getTopN(baseDate, pageOneBased, size); + return rankingAssembler.assemble(baseDate, total, entries); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java new file mode 100644 index 0000000000..a7452df895 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java @@ -0,0 +1,50 @@ +package com.loopers.application.ranking; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.ranking.RankingEntry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 랭킹 항목 조립기. + * + * 저장소에서 가져온 RankingEntry 목록에 상품 가시성 필터링을 적용하고 + * RankingPageResult 로 조립한다. 일간/주간/월간 Facade 가 공통으로 사용한다. + */ +@Component +@RequiredArgsConstructor +public class RankingAssembler { + + public static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final ProductFacade productFacade; + + /** + * RankingEntry 목록을 상품 가시성 필터링 후 RankingPageResult 로 조립한다. + * + * 삭제/숨김 상태인 상품은 결과에서 제외되므로 반환 items 수가 entries 수보다 작을 수 있다. + */ + public RankingPageResult assemble(LocalDate effectiveDate, long total, List entries) { + if (entries.isEmpty()) return new RankingPageResult(effectiveDate, total, List.of()); + + List productIds = entries.stream().map(RankingEntry::productId).toList(); + Map products = productFacade.findVisibleByIds(productIds); + + List items = entries.stream() + .map(entry -> { + ProductInfo info = products.get(entry.productId()); + return info == null ? null : RankingItemInfo.of(entry, info); + }) + .filter(Objects::nonNull) + .toList(); + + return new RankingPageResult(effectiveDate, total, items); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java new file mode 100644 index 0000000000..99df06d0a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java @@ -0,0 +1,21 @@ +package com.loopers.application.ranking; + +import java.time.LocalDate; +import java.util.List; + +/** + * 랭킹 페이지 조회 결과 VO. + * + * Facade 가 items, total, effectiveDate 를 하나로 묶어 반환함으로써 + * Controller 가 Facade 의 내부 날짜 계산 메서드를 직접 호출하지 않아도 된다. + * + * @param effectiveDate 실제 조회에 사용된 기준일 (요청 date 가 null 이면 Facade 기본값이 적용된다) + * @param total 해당 기준일의 전체 랭킹 엔트리 수 + * @param items 가시성 필터링 후 상품 정보가 합산된 랭킹 목록 + */ +public record RankingPageResult( + LocalDate effectiveDate, + long total, + List items +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingFacade.java new file mode 100644 index 0000000000..056cf9b7f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; + +/** + * 주간 랭킹 Use Case Facade. + * + * MV 테이블 조회 후 상품 가시성 필터링과 RankingItemInfo 조립을 RankingAssembler 에 위임한다. + * + * date 가 null 이면 KST 기준 어제 날짜를 기본값으로 사용한다. + * batch 가 targetDate - 1일을 base_date 로 적재하므로 최신 주간 랭킹의 기본 기준일은 어제다. + */ +@Component +@RequiredArgsConstructor +public class WeeklyRankingFacade { + + private final WeeklyRankingRepository weeklyRankingRepository; + private final RankingAssembler rankingAssembler; + private final Clock clock; + + /** + * 주간 랭킹 목록을 조회하고 상품 정보를 합산하여 반환한다. + * + * @param date 조회 기준일. null 이면 KST 어제 날짜를 사용한다. + * @param pageOneBased 1-based 페이지 번호 + * @param size 페이지 크기 + * @return 가시성 필터링 후 RankingItemInfo 목록과 페이지 메타데이터. + */ + public RankingPageResult getWeeklyRanking(LocalDate date, int pageOneBased, int size) { + LocalDate baseDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)).minusDays(1); + + long total = weeklyRankingRepository.getTotal(baseDate); + List entries = weeklyRankingRepository.getTopN(baseDate, pageOneBased, size); + return rankingAssembler.assemble(baseDate, total, entries); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java new file mode 100644 index 0000000000..9bfaf58e45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.MonthlyRankingFacade; +import com.loopers.application.ranking.RankingPageResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 월간 랭킹 조회 API V1. + * + * GET /api/v1/rankings/monthly?date=yyyyMMdd&page=1&size=20 + * + * date : MV 테이블의 base_date (batch 실행일 - 1일). 생략 시 KST 어제 날짜 기준. + * page : 1-based 페이지 번호. 기본값 1. 0 이하이면 1로 보정. + * size : 페이지 크기. 기본값 20. 0 이하이면 20, 100 초과이면 100으로 보정. + * + * WeeklyRankingV1Controller 와 구조가 동일하며, 대상 Facade 와 엔드포인트만 다르다. + */ +@RestController +@RequestMapping("/api/v1/rankings/monthly") +@RequiredArgsConstructor +public class MonthlyRankingV1Controller { + + private final MonthlyRankingFacade monthlyRankingFacade; + + @GetMapping + public ApiResponse getMonthlyRanking( + @RequestParam(value = "date", required = false) String dateStr, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + RankingPageQuery query = RankingPageQuery.of(dateStr, page, size); + RankingPageResult result = monthlyRankingFacade.getMonthlyRanking(query.date(), query.page(), query.size()); + + List itemResponses = result.items().stream() + .map(RankingV1Dto.RankingItemResponse::from) + .toList(); + + return ApiResponse.success(new RankingV1Dto.RankingPageResponse( + query.formattedDate(result.effectiveDate()), + query.page(), + query.size(), + result.total(), + itemResponses + )); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingPageQuery.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingPageQuery.java new file mode 100644 index 0000000000..93b9e8c7c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingPageQuery.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 랭킹 조회 요청 파라미터 파싱 결과. + * + * 세 랭킹 Controller(일간/주간/월간)가 공유하는 파라미터 파싱·보정 로직을 한 곳에 모은다. + */ +record RankingPageQuery(LocalDate date, int page, int size) { + + static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); + static final int DEFAULT_PAGE = 1; + static final int DEFAULT_SIZE = 20; + static final int MAX_SIZE = 100; + + /** + * 요청 파라미터를 파싱하고 보정하여 RankingPageQuery 를 생성한다. + * + * - date : null 또는 공백이면 null 반환 (Facade 기본값 처리에 위임) + * - page : 1 미만이면 1로 보정 + * - size : 0 이하이면 DEFAULT_SIZE, MAX_SIZE 초과이면 MAX_SIZE 로 보정 + * + * @throws CoreException BAD_REQUEST — dateStr 형식이 yyyyMMdd 가 아닌 경우 + */ + static RankingPageQuery of(String dateStr, int page, int size) { + LocalDate date = parseDate(dateStr); + int safePage = Math.max(page, DEFAULT_PAGE); + int safeSize = size <= 0 ? DEFAULT_SIZE : Math.min(size, MAX_SIZE); + return new RankingPageQuery(date, safePage, safeSize); + } + + /** effectiveDate 를 yyyyMMdd 형식 문자열로 변환한다. */ + String formattedDate(LocalDate effectiveDate) { + return effectiveDate.format(YYYYMMDD); + } + + private static LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) return null; + try { + return LocalDate.parse(dateStr, YYYYMMDD); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "date 는 yyyyMMdd 형식이어야 합니다: " + dateStr, e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java new file mode 100644 index 0000000000..6e2725da87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java @@ -0,0 +1,55 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingPageResult; +import com.loopers.application.ranking.WeeklyRankingFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 주간 랭킹 조회 API V1. + * + * GET /api/v1/rankings/weekly?date=yyyyMMdd&page=1&size=20 + * + * date : MV 테이블의 base_date (batch 실행일 - 1일). 생략 시 KST 어제 날짜 기준. + * page : 1-based 페이지 번호. 기본값 1. 0 이하이면 1로 보정. + * size : 페이지 크기. 기본값 20. 0 이하이면 20, 100 초과이면 100으로 보정. + * + * 응답의 date 필드는 실제 조회에 사용된 base_date 를 yyyyMMdd 형식으로 반환한다. + * 요청 date 를 생략하면 응답 date 는 어제 날짜가 된다. + */ +@RestController +@RequestMapping("/api/v1/rankings/weekly") +@RequiredArgsConstructor +public class WeeklyRankingV1Controller { + + private final WeeklyRankingFacade weeklyRankingFacade; + + @GetMapping + public ApiResponse getWeeklyRanking( + @RequestParam(value = "date", required = false) String dateStr, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + RankingPageQuery query = RankingPageQuery.of(dateStr, page, size); + RankingPageResult result = weeklyRankingFacade.getWeeklyRanking(query.date(), query.page(), query.size()); + + List itemResponses = result.items().stream() + .map(RankingV1Dto.RankingItemResponse::from) + .toList(); + + return ApiResponse.success(new RankingV1Dto.RankingPageResponse( + query.formattedDate(result.effectiveDate()), + query.page(), + query.size(), + result.total(), + itemResponses + )); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/MonthlyRankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/MonthlyRankingFacadeTest.java new file mode 100644 index 0000000000..c7a8d7bd30 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/MonthlyRankingFacadeTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.MonthlyRankingRepository; +import com.loopers.domain.ranking.RankingEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("MonthlyRankingFacade 단위 테스트") +class MonthlyRankingFacadeTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final LocalDate TODAY = LocalDate.of(2026, 4, 12); + private static final LocalDate YESTERDAY = TODAY.minusDays(1); + private static final Clock FIXED = + Clock.fixed(TODAY.atStartOfDay(KST).plusHours(10).toInstant(), KST); + + private MonthlyRankingRepository monthlyRankingRepository; + private RankingAssembler rankingAssembler; + private MonthlyRankingFacade facade; + + @BeforeEach + void setUp() { + monthlyRankingRepository = mock(MonthlyRankingRepository.class); + rankingAssembler = mock(RankingAssembler.class); + facade = new MonthlyRankingFacade(monthlyRankingRepository, rankingAssembler, FIXED); + } + + @Nested + @DisplayName("getMonthlyRanking") + class GetMonthlyRanking { + + @Test + @DisplayName("조회한 total 과 entries 를 RankingAssembler 에 위임한다") + void delegatesToAssembler() { + // given + List entries = List.of( + new RankingEntry(1L, 1L, 10.0), + new RankingEntry(2L, 2L, 5.0) + ); + when(monthlyRankingRepository.getTotal(YESTERDAY)).thenReturn(2L); + when(monthlyRankingRepository.getTopN(YESTERDAY, 1, 20)).thenReturn(entries); + when(rankingAssembler.assemble(YESTERDAY, 2L, entries)) + .thenReturn(new RankingPageResult(YESTERDAY, 2L, List.of())); + + // when + facade.getMonthlyRanking(YESTERDAY, 1, 20); + + // then + verify(rankingAssembler).assemble(YESTERDAY, 2L, entries); + } + + @Test + @DisplayName("date 가 null 이면 KST 어제 날짜 기준으로 저장소를 조회한다") + void nullDateUsesYesterdayForQuery() { + // given + when(monthlyRankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); + when(rankingAssembler.assemble(any(), anyLong(), any())) + .thenReturn(new RankingPageResult(YESTERDAY, 0L, List.of())); + + // when + RankingPageResult result = facade.getMonthlyRanking(null, 1, 20); + + // then — FIXED clock 은 2026-04-12, 어제 = 2026-04-11 + verify(monthlyRankingRepository).getTopN(YESTERDAY, 1, 20); + assertThat(result.effectiveDate()).isEqualTo(YESTERDAY); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java new file mode 100644 index 0000000000..2e4fed602e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java @@ -0,0 +1,106 @@ +package com.loopers.application.ranking; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductStatus; +import com.loopers.domain.ranking.RankingEntry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("RankingAssembler 단위 테스트") +class RankingAssemblerTest { + + private static final LocalDate BASE_DATE = LocalDate.of(2026, 4, 11); + + private ProductFacade productFacade; + private RankingAssembler assembler; + + @BeforeEach + void setUp() { + productFacade = mock(ProductFacade.class); + assembler = new RankingAssembler(productFacade); + } + + private ProductInfo stubProduct(Long id) { + return new ProductInfo(id, 1L, "브랜드", "상품" + id, 10000, 9000, 2500, 0, + ProductStatus.ON_SALE, "Y", ZonedDateTime.now()); + } + + @Nested + @DisplayName("assemble") + class Assemble { + + @Test + @DisplayName("RankingEntry 목록과 상품 정보를 결합하여 RankingPageResult 를 반환한다") + void happyPath() { + // given + List entries = List.of( + new RankingEntry(1L, 1L, 5.0), + new RankingEntry(2L, 2L, 3.0) + ); + when(productFacade.findVisibleByIds(List.of(1L, 2L))).thenReturn(Map.of( + 1L, stubProduct(1L), + 2L, stubProduct(2L) + )); + + // when + RankingPageResult result = assembler.assemble(BASE_DATE, 2L, entries); + + // then + assertThat(result.effectiveDate()).isEqualTo(BASE_DATE); + assertThat(result.total()).isEqualTo(2L); + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).rank()).isEqualTo(1L); + assertThat(result.items().get(0).product().id()).isEqualTo(1L); + assertThat(result.items().get(1).rank()).isEqualTo(2L); + assertThat(result.items().get(1).product().id()).isEqualTo(2L); + } + + @Test + @DisplayName("삭제/숨김 상품은 응답에서 제외되고 items 수가 줄어든다") + void visibilityFilter() { + // given — 3개 엔트리, 2번 상품만 visible + List entries = List.of( + new RankingEntry(1L, 1L, 5.0), + new RankingEntry(2L, 2L, 4.0), + new RankingEntry(3L, 3L, 3.0) + ); + when(productFacade.findVisibleByIds(List.of(1L, 2L, 3L))) + .thenReturn(Map.of(2L, stubProduct(2L))); + + // when + RankingPageResult result = assembler.assemble(BASE_DATE, 3L, entries); + + // then — 2번만 남음, 원 rank 유지 + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).product().id()).isEqualTo(2L); + assertThat(result.items().get(0).rank()).isEqualTo(2L); + } + + @Test + @DisplayName("entries 가 비어 있으면 productFacade 를 호출하지 않고 빈 목록을 반환한다") + void emptyEntries() { + // when + RankingPageResult result = assembler.assemble(BASE_DATE, 0L, List.of()); + + // then + assertThat(result.effectiveDate()).isEqualTo(BASE_DATE); + assertThat(result.total()).isEqualTo(0L); + assertThat(result.items()).isEmpty(); + verify(productFacade, never()).findVisibleByIds(List.of()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/WeeklyRankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/WeeklyRankingFacadeTest.java new file mode 100644 index 0000000000..a975b20df7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/WeeklyRankingFacadeTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankingEntry; +import com.loopers.domain.ranking.WeeklyRankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("WeeklyRankingFacade 단위 테스트") +class WeeklyRankingFacadeTest { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final LocalDate TODAY = LocalDate.of(2026, 4, 12); + private static final LocalDate YESTERDAY = TODAY.minusDays(1); + private static final Clock FIXED = + Clock.fixed(TODAY.atStartOfDay(KST).plusHours(10).toInstant(), KST); + + private WeeklyRankingRepository weeklyRankingRepository; + private RankingAssembler rankingAssembler; + private WeeklyRankingFacade facade; + + @BeforeEach + void setUp() { + weeklyRankingRepository = mock(WeeklyRankingRepository.class); + rankingAssembler = mock(RankingAssembler.class); + facade = new WeeklyRankingFacade(weeklyRankingRepository, rankingAssembler, FIXED); + } + + @Nested + @DisplayName("getWeeklyRanking") + class GetWeeklyRanking { + + @Test + @DisplayName("조회한 total 과 entries 를 RankingAssembler 에 위임한다") + void delegatesToAssembler() { + // given + List entries = List.of( + new RankingEntry(1L, 1L, 10.0), + new RankingEntry(2L, 2L, 5.0) + ); + when(weeklyRankingRepository.getTotal(YESTERDAY)).thenReturn(2L); + when(weeklyRankingRepository.getTopN(YESTERDAY, 1, 20)).thenReturn(entries); + when(rankingAssembler.assemble(YESTERDAY, 2L, entries)) + .thenReturn(new RankingPageResult(YESTERDAY, 2L, List.of())); + + // when + facade.getWeeklyRanking(YESTERDAY, 1, 20); + + // then + verify(rankingAssembler).assemble(YESTERDAY, 2L, entries); + } + + @Test + @DisplayName("date 가 null 이면 KST 어제 날짜 기준으로 저장소를 조회한다") + void nullDateUsesYesterdayForQuery() { + // given + when(weeklyRankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); + when(rankingAssembler.assemble(any(), anyLong(), any())) + .thenReturn(new RankingPageResult(YESTERDAY, 0L, List.of())); + + // when + RankingPageResult result = facade.getWeeklyRanking(null, 1, 20); + + // then — FIXED clock 은 2026-04-12, 어제 = 2026-04-11 + verify(weeklyRankingRepository).getTopN(YESTERDAY, 1, 20); + assertThat(result.effectiveDate()).isEqualTo(YESTERDAY); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MonthlyRankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MonthlyRankingV1ApiE2ETest.java new file mode 100644 index 0000000000..855a25dc1a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MonthlyRankingV1ApiE2ETest.java @@ -0,0 +1,240 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 월간 랭킹 API E2E — HTTP → Controller → Facade → DB (MV 테이블). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("MonthlyRankingV1 API E2E") +class MonthlyRankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings/monthly"; + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Autowired + private Clock clock; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Brand saveBrand() { + Brand brand = new Brand("브랜드", "설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name, String displayYn) { + Product product = new Product(brand, name, 10000, 9000, 1000, 2500, + "설명", MarginType.AMOUNT, ProductStatus.ON_SALE, displayYn, List.of()); + return productJpaRepository.save(product); + } + + private void seedMonthlyRanking(Long productId, LocalDate baseDate, int rank, double score) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)", + productId, baseDate, rank, score, LocalDateTime.now() + ); + } + + @Nested + @DisplayName("GET /api/v1/rankings/monthly") + class GetMonthlyRanking { + + @Test + @DisplayName("200 — 상품 정보가 Aggregation 된 월간 랭킹 Page 를 반환한다.") + void happyPath() { + // given + Brand brand = saveBrand(); + Product p1 = saveProduct(brand, "상품1", "Y"); + Product p2 = saveProduct(brand, "상품2", "Y"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedMonthlyRanking(p1.getId(), baseDate, 1, 10.0); + seedMonthlyRanking(p2.getId(), baseDate, 2, 5.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD) + "&page=1&size=20", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(2); + assertThat(body.items().get(0).rank()).isEqualTo(1L); + assertThat(body.items().get(0).productId()).isEqualTo(p1.getId()); + assertThat(body.items().get(0).name()).isEqualTo("상품1"); + assertThat(body.items().get(1).rank()).isEqualTo(2L); + assertThat(body.items().get(1).productId()).isEqualTo(p2.getId()); + assertThat(body.totalElements()).isEqualTo(2L); + assertThat(body.date()).isEqualTo(baseDate.format(YYYYMMDD)); + } + + @Test + @DisplayName("숨김/삭제 상품은 응답에서 제외된다.") + void visibilityFilter() { + // given + Brand brand = saveBrand(); + Product visible = saveProduct(brand, "visible", "Y"); + Product hidden = saveProduct(brand, "hidden", "N"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedMonthlyRanking(hidden.getId(), baseDate, 1, 20.0); + seedMonthlyRanking(visible.getId(), baseDate, 2, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.items().get(0).productId()).isEqualTo(visible.getId()); + } + + @Test + @DisplayName("date 파라미터 생략 시 어제 날짜 기준 월간 랭킹을 조회한다.") + void defaultToYesterday() { + // given + Brand brand = saveBrand(); + Product product = saveProduct(brand, "상품", "Y"); + LocalDate yesterday = LocalDate.now(clock).minusDays(1); + seedMonthlyRanking(product.getId(), yesterday, 1, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.date()).isEqualTo(yesterday.format(YYYYMMDD)); + } + + @Test + @DisplayName("잘못된 date 포맷은 400을 반환한다.") + void badDateFormat() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=2026-04-09", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("page=2, size=2 요청 시 2페이지 데이터를 반환한다.") + void pagination() { + // given + Brand brand = saveBrand(); + Product p1 = saveProduct(brand, "상품1", "Y"); + Product p2 = saveProduct(brand, "상품2", "Y"); + Product p3 = saveProduct(brand, "상품3", "Y"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedMonthlyRanking(p1.getId(), baseDate, 1, 30.0); + seedMonthlyRanking(p2.getId(), baseDate, 2, 20.0); + seedMonthlyRanking(p3.getId(), baseDate, 3, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD) + "&page=2&size=2", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then — 3번째 상품(rank=3)만 반환, totalElements는 전체 3건 + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.items().get(0).productId()).isEqualTo(p3.getId()); + assertThat(body.items().get(0).rank()).isEqualTo(3L); + assertThat(body.totalElements()).isEqualTo(3L); + } + + @Test + @DisplayName("size=200 요청 시 100으로 보정된 size 가 응답에 반영된다.") + void sizeClampedToMax() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + LocalDate.now(clock).minusDays(1).format(YYYYMMDD) + "&size=200", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().size()).isEqualTo(100); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 items 와 totalElements=0 을 반환한다.") + void emptyRanking() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + LocalDate.now(clock).minusDays(1).format(YYYYMMDD), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).isEmpty(); + assertThat(body.totalElements()).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/WeeklyRankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/WeeklyRankingV1ApiE2ETest.java new file mode 100644 index 0000000000..95ca935ba8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/WeeklyRankingV1ApiE2ETest.java @@ -0,0 +1,240 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandStatus; +import com.loopers.domain.product.MarginType; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductStatus; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 주간 랭킹 API E2E — HTTP → Controller → Facade → DB (MV 테이블). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("WeeklyRankingV1 API E2E") +class WeeklyRankingV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/rankings/weekly"; + private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Autowired + private Clock clock; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Brand saveBrand() { + Brand brand = new Brand("브랜드", "설명"); + brand.changeStatus(BrandStatus.ACTIVE); + return brandJpaRepository.save(brand); + } + + private Product saveProduct(Brand brand, String name, String displayYn) { + Product product = new Product(brand, name, 10000, 9000, 1000, 2500, + "설명", MarginType.AMOUNT, ProductStatus.ON_SALE, displayYn, List.of()); + return productJpaRepository.save(product); + } + + private void seedWeeklyRanking(Long productId, LocalDate baseDate, int rank, double score) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)", + productId, baseDate, rank, score, LocalDateTime.now() + ); + } + + @Nested + @DisplayName("GET /api/v1/rankings/weekly") + class GetWeeklyRanking { + + @Test + @DisplayName("200 — 상품 정보가 Aggregation 된 주간 랭킹 Page 를 반환한다.") + void happyPath() { + // given + Brand brand = saveBrand(); + Product p1 = saveProduct(brand, "상품1", "Y"); + Product p2 = saveProduct(brand, "상품2", "Y"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedWeeklyRanking(p1.getId(), baseDate, 1, 10.0); + seedWeeklyRanking(p2.getId(), baseDate, 2, 5.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD) + "&page=1&size=20", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(2); + assertThat(body.items().get(0).rank()).isEqualTo(1L); + assertThat(body.items().get(0).productId()).isEqualTo(p1.getId()); + assertThat(body.items().get(0).name()).isEqualTo("상품1"); + assertThat(body.items().get(1).rank()).isEqualTo(2L); + assertThat(body.items().get(1).productId()).isEqualTo(p2.getId()); + assertThat(body.totalElements()).isEqualTo(2L); + assertThat(body.date()).isEqualTo(baseDate.format(YYYYMMDD)); + } + + @Test + @DisplayName("숨김/삭제 상품은 응답에서 제외된다.") + void visibilityFilter() { + // given + Brand brand = saveBrand(); + Product visible = saveProduct(brand, "visible", "Y"); + Product hidden = saveProduct(brand, "hidden", "N"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedWeeklyRanking(hidden.getId(), baseDate, 1, 20.0); + seedWeeklyRanking(visible.getId(), baseDate, 2, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.items().get(0).productId()).isEqualTo(visible.getId()); + } + + @Test + @DisplayName("date 파라미터 생략 시 어제 날짜 기준 주간 랭킹을 조회한다.") + void defaultToYesterday() { + // given + Brand brand = saveBrand(); + Product product = saveProduct(brand, "상품", "Y"); + LocalDate yesterday = LocalDate.now(clock).minusDays(1); + seedWeeklyRanking(product.getId(), yesterday, 1, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.date()).isEqualTo(yesterday.format(YYYYMMDD)); + } + + @Test + @DisplayName("잘못된 date 포맷은 400을 반환한다.") + void badDateFormat() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=2026-04-09", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("page=2, size=2 요청 시 2페이지 데이터를 반환한다.") + void pagination() { + // given + Brand brand = saveBrand(); + Product p1 = saveProduct(brand, "상품1", "Y"); + Product p2 = saveProduct(brand, "상품2", "Y"); + Product p3 = saveProduct(brand, "상품3", "Y"); + LocalDate baseDate = LocalDate.now(clock).minusDays(1); + seedWeeklyRanking(p1.getId(), baseDate, 1, 30.0); + seedWeeklyRanking(p2.getId(), baseDate, 2, 20.0); + seedWeeklyRanking(p3.getId(), baseDate, 3, 10.0); + + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + baseDate.format(YYYYMMDD) + "&page=2&size=2", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then — 3번째 상품(rank=3)만 반환, totalElements는 전체 3건 + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).hasSize(1); + assertThat(body.items().get(0).productId()).isEqualTo(p3.getId()); + assertThat(body.items().get(0).rank()).isEqualTo(3L); + assertThat(body.totalElements()).isEqualTo(3L); + } + + @Test + @DisplayName("size=200 요청 시 100으로 보정된 size 가 응답에 반영된다.") + void sizeClampedToMax() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + LocalDate.now(clock).minusDays(1).format(YYYYMMDD) + "&size=200", + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().data().size()).isEqualTo(100); + } + + @Test + @DisplayName("랭킹 데이터가 없으면 빈 items 와 totalElements=0 을 반환한다.") + void emptyRanking() { + // when + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=" + LocalDate.now(clock).minusDays(1).format(YYYYMMDD), + HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // then + RankingV1Dto.RankingPageResponse body = response.getBody().data(); + assertThat(body.items()).isEmpty(); + assertThat(body.totalElements()).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1ControllerTest.java new file mode 100644 index 0000000000..d76994770c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1ControllerTest.java @@ -0,0 +1,164 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.ranking.MonthlyRankingFacade; +import com.loopers.application.ranking.RankingItemInfo; +import com.loopers.application.ranking.RankingPageResult; +import com.loopers.config.WebMvcConfig; +import com.loopers.domain.auth.LdapAuthService; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.ProductStatus; +import com.loopers.interfaces.api.auth.AdminAuthInterceptor; +import com.loopers.interfaces.api.auth.LoginAdminArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; +import com.loopers.interfaces.api.auth.MemberAuthInterceptor; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MonthlyRankingV1Controller.class) +@Import({WebMvcConfig.class, MemberAuthInterceptor.class, LoginMemberArgumentResolver.class, + AdminAuthInterceptor.class, LoginAdminArgumentResolver.class}) +@DisplayName("MonthlyRankingV1Controller 단위 테스트") +class MonthlyRankingV1ControllerTest { + + private static final String ENDPOINT = "/api/v1/rankings/monthly"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private MonthlyRankingFacade monthlyRankingFacade; + + @MockBean + private MemberService memberService; + + @MockBean + private LdapAuthService ldapAuthService; + + private RankingItemInfo stubItem(long rank) { + ProductInfo product = new ProductInfo( + rank, 1L, "브랜드", "상품" + rank, 10000, 9000, 2500, 0, + ProductStatus.ON_SALE, "Y", ZonedDateTime.now()); + return new RankingItemInfo(rank, 10.0 / rank, product); + } + + @Nested + @DisplayName("GET /api/v1/rankings/monthly") + class GetMonthlyRanking { + + @Test + @DisplayName("200 — Facade 결과가 JSON으로 올바르게 직렬화된다.") + void happyPath() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + List items = List.of(stubItem(1), stubItem(2)); + when(monthlyRankingFacade.getMonthlyRanking(eq(baseDate), eq(1), eq(20))) + .thenReturn(new RankingPageResult(baseDate, 2L, items)); + + // when & then + mockMvc.perform(get(ENDPOINT) + .param("date", "20260411") + .param("page", "1") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("20260411")) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items[0].rank").value(1)) + .andExpect(jsonPath("$.data.items[1].rank").value(2)); + } + + @Test + @DisplayName("date 파라미터 생략 시 Facade 에 null 이 전달되어 날짜 기본값 처리가 위임된다.") + void nullDatePassedToFacade() throws Exception { + // given + LocalDate yesterday = LocalDate.of(2026, 4, 11); + when(monthlyRankingFacade.getMonthlyRanking(isNull(), eq(1), eq(20))) + .thenReturn(new RankingPageResult(yesterday, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT)) + .andExpect(status().isOk()); + + verify(monthlyRankingFacade).getMonthlyRanking(null, 1, 20); + } + + @Test + @DisplayName("page=0 은 1로 보정되어 Facade 에 전달된다.") + void pageZeroClampedToOne() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(monthlyRankingFacade.getMonthlyRanking(any(), eq(1), anyInt())) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(1)); + + verify(monthlyRankingFacade).getMonthlyRanking(any(), eq(1), anyInt()); + } + + @Test + @DisplayName("size=200 은 100으로 보정되어 Facade 에 전달된다.") + void sizeClampedToMax() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(monthlyRankingFacade.getMonthlyRanking(any(), anyInt(), eq(100))) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("size", "200")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size").value(100)); + + verify(monthlyRankingFacade).getMonthlyRanking(any(), anyInt(), eq(100)); + } + + @Test + @DisplayName("size=0 은 기본값 20으로 보정되어 Facade 에 전달된다.") + void sizeZeroClampedToDefault() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(monthlyRankingFacade.getMonthlyRanking(any(), anyInt(), eq(20))) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("size", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size").value(20)); + + verify(monthlyRankingFacade).getMonthlyRanking(any(), anyInt(), eq(20)); + } + + @Test + @DisplayName("잘못된 date 포맷은 400 BAD_REQUEST 를 반환한다.") + void badDateFormat() throws Exception { + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "2026-04-11")) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1ControllerTest.java new file mode 100644 index 0000000000..9000c35492 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1ControllerTest.java @@ -0,0 +1,164 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.ranking.RankingItemInfo; +import com.loopers.application.ranking.RankingPageResult; +import com.loopers.application.ranking.WeeklyRankingFacade; +import com.loopers.config.WebMvcConfig; +import com.loopers.domain.auth.LdapAuthService; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.ProductStatus; +import com.loopers.interfaces.api.auth.AdminAuthInterceptor; +import com.loopers.interfaces.api.auth.LoginAdminArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; +import com.loopers.interfaces.api.auth.MemberAuthInterceptor; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(WeeklyRankingV1Controller.class) +@Import({WebMvcConfig.class, MemberAuthInterceptor.class, LoginMemberArgumentResolver.class, + AdminAuthInterceptor.class, LoginAdminArgumentResolver.class}) +@DisplayName("WeeklyRankingV1Controller 단위 테스트") +class WeeklyRankingV1ControllerTest { + + private static final String ENDPOINT = "/api/v1/rankings/weekly"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private WeeklyRankingFacade weeklyRankingFacade; + + @MockBean + private MemberService memberService; + + @MockBean + private LdapAuthService ldapAuthService; + + private RankingItemInfo stubItem(long rank) { + ProductInfo product = new ProductInfo( + rank, 1L, "브랜드", "상품" + rank, 10000, 9000, 2500, 0, + ProductStatus.ON_SALE, "Y", ZonedDateTime.now()); + return new RankingItemInfo(rank, 10.0 / rank, product); + } + + @Nested + @DisplayName("GET /api/v1/rankings/weekly") + class GetWeeklyRanking { + + @Test + @DisplayName("200 — Facade 결과가 JSON으로 올바르게 직렬화된다.") + void happyPath() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + List items = List.of(stubItem(1), stubItem(2)); + when(weeklyRankingFacade.getWeeklyRanking(eq(baseDate), eq(1), eq(20))) + .thenReturn(new RankingPageResult(baseDate, 2L, items)); + + // when & then + mockMvc.perform(get(ENDPOINT) + .param("date", "20260411") + .param("page", "1") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.date").value("20260411")) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalElements").value(2)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items[0].rank").value(1)) + .andExpect(jsonPath("$.data.items[1].rank").value(2)); + } + + @Test + @DisplayName("date 파라미터 생략 시 Facade 에 null 이 전달되어 날짜 기본값 처리가 위임된다.") + void nullDatePassedToFacade() throws Exception { + // given + LocalDate yesterday = LocalDate.of(2026, 4, 11); + when(weeklyRankingFacade.getWeeklyRanking(isNull(), eq(1), eq(20))) + .thenReturn(new RankingPageResult(yesterday, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT)) + .andExpect(status().isOk()); + + verify(weeklyRankingFacade).getWeeklyRanking(null, 1, 20); + } + + @Test + @DisplayName("page=0 은 1로 보정되어 Facade 에 전달된다.") + void pageZeroClampedToOne() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(weeklyRankingFacade.getWeeklyRanking(any(), eq(1), anyInt())) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("page", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(1)); + + verify(weeklyRankingFacade).getWeeklyRanking(any(), eq(1), anyInt()); + } + + @Test + @DisplayName("size=200 은 100으로 보정되어 Facade 에 전달된다.") + void sizeClampedToMax() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(weeklyRankingFacade.getWeeklyRanking(any(), anyInt(), eq(100))) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("size", "200")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size").value(100)); + + verify(weeklyRankingFacade).getWeeklyRanking(any(), anyInt(), eq(100)); + } + + @Test + @DisplayName("size=0 은 기본값 20으로 보정되어 Facade 에 전달된다.") + void sizeZeroClampedToDefault() throws Exception { + // given + LocalDate baseDate = LocalDate.of(2026, 4, 11); + when(weeklyRankingFacade.getWeeklyRanking(any(), anyInt(), eq(20))) + .thenReturn(new RankingPageResult(baseDate, 0L, List.of())); + + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "20260411").param("size", "0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.size").value(20)); + + verify(weeklyRankingFacade).getWeeklyRanking(any(), anyInt(), eq(20)); + } + + @Test + @DisplayName("잘못된 date 포맷은 400 BAD_REQUEST 를 반환한다.") + void badDateFormat() throws Exception { + // when & then + mockMvc.perform(get(ENDPOINT).param("date", "2026-04-11")) + .andExpect(status().isBadRequest()); + } + } +} From 4060946affcd4ee8d5c098768af46c1eeb636ee5 Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Thu, 16 Apr 2026 23:14:43 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20=EC=9D=BC?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20RankingAssembler/RankingPageQu?= =?UTF-8?q?ery=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingFacade: RankingPageResult 반환으로 변경, RankingAssembler 주입으로 ProductFacade 직접 의존 제거 - getDailyTotal, today() 등 내부 메서드 외부 노출 제거 - RankingV1Controller: RankingPageQuery.of() 한 줄로 파라미터 파싱 통일 - 주간/월간 Controller 와 동일한 패턴으로 일관성 확보 - RankingFacadeTest: 반환 타입 RankingPageResult 기반으로 검증 방식 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../application/ranking/RankingFacade.java | 49 ++-------- .../api/ranking/RankingV1Controller.java | 52 +++-------- .../ranking/RankingFacadeTest.java | 91 ++++--------------- 3 files changed, 41 insertions(+), 151 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index d53743d234..7673ce6756 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -1,7 +1,5 @@ package com.loopers.application.ranking; -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingKey; import com.loopers.domain.ranking.RankingRepository; @@ -10,11 +8,7 @@ import java.time.Clock; import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Objects; /** @@ -22,55 +16,30 @@ * * - {@link #getDailyRanking(LocalDate, int, int)} : 랭킹 Page 조회 + 상품 정보 Aggregation * - {@link #getDailyRank(Long)} : 상품 상세의 `dailyRank` 합성용 - * - {@link #getDailyTotal(LocalDate)} : 페이지네이션 totalElements 제공 */ @Component @RequiredArgsConstructor public class RankingFacade { - public static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final RankingRepository rankingRepository; - private final ProductFacade productFacade; + private final RankingAssembler rankingAssembler; private final Clock clock; /** - * KST 기준 일간 랭킹 상위 항목을 조회한다. + * 일간 랭킹을 조회하고 상품 정보를 합산하여 반환한다. * * 삭제/숨김 상태인 상품은 결과에서 제외되므로, 반환 size 가 요청 size 보다 작을 수 있다. - * date 가 null 이면 오늘 날짜로 처리한다. + * date 가 null 이면 KST 오늘 날짜로 처리한다. * * @param pageOneBased 사용자 노출 기준 페이지 번호 (1-based) */ - public List getDailyRanking(LocalDate date, int pageOneBased, int size) { - if (date == null) date = LocalDate.now(clock.withZone(KST)); - String key = RankingKey.daily(date); + public RankingPageResult getDailyRanking(LocalDate date, int pageOneBased, int size) { + LocalDate effectiveDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)); + String key = RankingKey.daily(effectiveDate); + long total = rankingRepository.getTotal(key); List entries = rankingRepository.getTopN(key, pageOneBased, size); - if (entries.isEmpty()) return Collections.emptyList(); - - List productIds = entries.stream().map(RankingEntry::productId).toList(); - Map products = productFacade.findVisibleByIds(productIds); - - List result = new ArrayList<>(entries.size()); - for (RankingEntry entry : entries) { - ProductInfo info = products.get(entry.productId()); - if (info == null) continue; // 삭제/숨김 상품 — 응답에서 제외 (size 축소 허용) - result.add(RankingItemInfo.of(entry, info)); - } - return result; - } - - public long getDailyTotal(LocalDate date) { - if (date == null) date = LocalDate.now(clock.withZone(KST)); - return rankingRepository.getTotal(RankingKey.daily(date)); - } - - /** - * KST 기준 "오늘" 을 반환한다 — controller / 응답 조립이 동일 clock 을 사용하도록. - */ - public LocalDate today() { - return LocalDate.now(clock.withZone(KST)); + return rankingAssembler.assemble(effectiveDate, total, entries); } /** @@ -79,7 +48,7 @@ public LocalDate today() { */ public Long getDailyRank(Long productId) { if (productId == null) return null; - LocalDate today = LocalDate.now(clock.withZone(KST)); + LocalDate today = LocalDate.now(clock.withZone(RankingAssembler.KST)); return rankingRepository.getRank(RankingKey.daily(today), productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index b18acfa6b6..3846472bd2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,43 +1,31 @@ package com.loopers.interfaces.api.ranking; import com.loopers.application.ranking.RankingFacade; -import com.loopers.application.ranking.RankingItemInfo; +import com.loopers.application.ranking.RankingPageResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.dto.RankingV1Dto; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.List; /** * 랭킹 조회 API V1. * - *
  * GET /api/v1/rankings?date=yyyyMMdd&page=1&size=20
- * 
* * 주의: - * - `page` 는 1-based (과제 명세의 `?page=1` 예시가 "첫 페이지" 를 의미) - * - `size` 는 상한 — 삭제/숨김 상품이 응답에서 제외되어 실제 반환 개수가 작을 수 있음 + * - page 는 1-based (과제 명세의 ?page=1 예시가 "첫 페이지" 를 의미) + * - size 는 상한 — 삭제/숨김 상품이 응답에서 제외되어 실제 반환 개수가 작을 수 있음 */ @RestController @RequestMapping("/api/v1/rankings") @RequiredArgsConstructor public class RankingV1Controller { - private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); - private static final int DEFAULT_PAGE = 1; - private static final int DEFAULT_SIZE = 20; - private static final int MAX_SIZE = 100; - private final RankingFacade rankingFacade; @GetMapping @@ -46,35 +34,19 @@ public ApiResponse getDailyRanking( @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "size", defaultValue = "20") int size ) { - LocalDate date = parseDate(dateStr); - int safePage = Math.max(page, DEFAULT_PAGE); - int safeSize = size <= 0 ? DEFAULT_SIZE : Math.min(size, MAX_SIZE); - - List items = rankingFacade.getDailyRanking(date, safePage, safeSize); - long total = rankingFacade.getDailyTotal(date); - // 응답 헤더의 date 필드도 facade 와 동일한 KST clock 기준으로 결정 - LocalDate effectiveDate = date != null ? date : rankingFacade.today(); + RankingPageQuery query = RankingPageQuery.of(dateStr, page, size); + RankingPageResult result = rankingFacade.getDailyRanking(query.date(), query.page(), query.size()); - List itemResponses = items.stream() + List itemResponses = result.items().stream() .map(RankingV1Dto.RankingItemResponse::from) .toList(); - RankingV1Dto.RankingPageResponse response = new RankingV1Dto.RankingPageResponse( - effectiveDate.format(YYYYMMDD), - safePage, - safeSize, - total, + return ApiResponse.success(new RankingV1Dto.RankingPageResponse( + query.formattedDate(result.effectiveDate()), + query.page(), + query.size(), + result.total(), itemResponses - ); - return ApiResponse.success(response); - } - - private LocalDate parseDate(String dateStr) { - if (dateStr == null || dateStr.isBlank()) return null; - try { - return LocalDate.parse(dateStr, YYYYMMDD); - } catch (DateTimeParseException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "date 는 yyyyMMdd 형식이어야 합니다: " + dateStr, e); - } + )); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index ff83bb466e..6dcb4751d8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -1,9 +1,5 @@ package com.loopers.application.ranking; -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; -import com.loopers.domain.product.ProductStatus; -import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -13,13 +9,12 @@ import java.time.Clock; import java.time.LocalDate; import java.time.ZoneId; -import java.time.ZonedDateTime; import java.util.List; -import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -34,19 +29,14 @@ class RankingFacadeTest { Clock.fixed(TODAY.atStartOfDay(KST).plusHours(10).toInstant(), KST); private RankingRepository rankingRepository; - private ProductFacade productFacade; + private RankingAssembler rankingAssembler; private RankingFacade facade; @BeforeEach void setUp() { rankingRepository = mock(RankingRepository.class); - productFacade = mock(ProductFacade.class); - facade = new RankingFacade(rankingRepository, productFacade, FIXED); - } - - private ProductInfo stubProduct(Long id) { - return new ProductInfo(id, 1L, "브랜드", "상품" + id, 10000, 9000, 2500, 0, - ProductStatus.ON_SALE, "Y", ZonedDateTime.now()); + rankingAssembler = mock(RankingAssembler.class); + facade = new RankingFacade(rankingRepository, rankingAssembler, FIXED); } @Nested @@ -54,57 +44,12 @@ private ProductInfo stubProduct(Long id) { class GetDailyRanking { @Test - @DisplayName("ZSET Top-N 과 상품 정보를 Aggregation 하여 반환") - void happyPath() { - // given - List entries = List.of( - new RankingEntry(1L, 1L, 5.0), - new RankingEntry(2L, 2L, 3.0) - ); - when(rankingRepository.getTopN("ranking:all:20260409", 1, 20)).thenReturn(entries); - when(productFacade.findVisibleByIds(List.of(1L, 2L))).thenReturn(Map.of( - 1L, stubProduct(1L), - 2L, stubProduct(2L) - )); - - // when - List result = facade.getDailyRanking(TODAY, 1, 20); - - // then - assertThat(result).hasSize(2); - assertThat(result.get(0).rank()).isEqualTo(1L); - assertThat(result.get(0).product().id()).isEqualTo(1L); - assertThat(result.get(1).rank()).isEqualTo(2L); - assertThat(result.get(1).product().id()).isEqualTo(2L); - } - - @Test - @DisplayName("삭제/숨김 상품은 응답에서 제외되고 size 는 축소된다") - void visibilityFilter() { - // given — 3개 엔트리, 2번 상품만 visible - List entries = List.of( - new RankingEntry(1L, 1L, 5.0), - new RankingEntry(2L, 2L, 4.0), - new RankingEntry(3L, 3L, 3.0) - ); - when(rankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(entries); - when(productFacade.findVisibleByIds(List.of(1L, 2L, 3L))) - .thenReturn(Map.of(2L, stubProduct(2L))); - - // when - List result = facade.getDailyRanking(TODAY, 1, 20); - - // then — 2번만 남음 - assertThat(result).hasSize(1); - assertThat(result.get(0).product().id()).isEqualTo(2L); - assertThat(result.get(0).rank()).isEqualTo(2L); // 원 rank 유지 - } - - @Test - @DisplayName("date 가 null 이면 KST 오늘 날짜로 조회") + @DisplayName("date 가 null 이면 KST 오늘 날짜 키로 저장소를 조회한다") void nullDateDefaultsToToday() { // given when(rankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); + when(rankingAssembler.assemble(any(), anyLong(), any())) + .thenReturn(new RankingPageResult(TODAY, 0L, List.of())); // when facade.getDailyRanking(null, 1, 20); @@ -114,16 +59,20 @@ void nullDateDefaultsToToday() { } @Test - @DisplayName("ZSET 이 비어 있으면 빈 리스트") - void emptyRanking() { + @DisplayName("조회한 total 과 entries 를 RankingAssembler 에 위임한다") + void delegatesToAssembler() { // given - when(rankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); + String key = "ranking:all:20260409"; + when(rankingRepository.getTotal(key)).thenReturn(5L); + when(rankingRepository.getTopN(key, 1, 20)).thenReturn(List.of()); + when(rankingAssembler.assemble(TODAY, 5L, List.of())) + .thenReturn(new RankingPageResult(TODAY, 5L, List.of())); // when - List result = facade.getDailyRanking(TODAY, 1, 20); + facade.getDailyRanking(TODAY, 1, 20); // then - assertThat(result).isEmpty(); + verify(rankingAssembler).assemble(TODAY, 5L, List.of()); } } @@ -175,7 +124,7 @@ void justBeforeMidnight_usesTodayKey() { LocalDate kstDate = LocalDate.of(2026, 4, 8); Clock justBefore = Clock.fixed( kstDate.atStartOfDay(KST).plusDays(1).minusSeconds(1).toInstant(), KST); - RankingFacade facadeBefore = new RankingFacade(rankingRepository, productFacade, justBefore); + RankingFacade facadeBefore = new RankingFacade(rankingRepository, rankingAssembler, justBefore); when(rankingRepository.getRank("ranking:all:20260408", 1L)).thenReturn(2L); // when @@ -192,7 +141,7 @@ void atMidnight_usesNextDayKey() { // given — 2026-04-09 00:00:00 KST Clock atMidnight = Clock.fixed( TODAY.atStartOfDay(KST).toInstant(), KST); - RankingFacade facadeAtMidnight = new RankingFacade(rankingRepository, productFacade, atMidnight); + RankingFacade facadeAtMidnight = new RankingFacade(rankingRepository, rankingAssembler, atMidnight); when(rankingRepository.getRank("ranking:all:20260409", 1L)).thenReturn(1L); // when @@ -210,7 +159,7 @@ void getDailyRanking_justBeforeMidnight_usesTodayKey() { LocalDate kstDate = LocalDate.of(2026, 4, 8); Clock justBefore = Clock.fixed( kstDate.atStartOfDay(KST).plusDays(1).minusSeconds(1).toInstant(), KST); - RankingFacade f = new RankingFacade(rankingRepository, productFacade, justBefore); + RankingFacade f = new RankingFacade(rankingRepository, rankingAssembler, justBefore); when(rankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); // when @@ -225,7 +174,7 @@ void getDailyRanking_justBeforeMidnight_usesTodayKey() { void getDailyRanking_atMidnight_usesNextDayKey() { // given — 2026-04-09 00:00:00 KST Clock atMidnight = Clock.fixed(TODAY.atStartOfDay(KST).toInstant(), KST); - RankingFacade f = new RankingFacade(rankingRepository, productFacade, atMidnight); + RankingFacade f = new RankingFacade(rankingRepository, rankingAssembler, atMidnight); when(rankingRepository.getTopN(any(), anyInt(), anyInt())).thenReturn(List.of()); // when From c36d72cb21c0748776d5c8a749661c65594c4a9a Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Fri, 17 Apr 2026 01:22:20 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20RankingAssemblerTest=20=EB=B9=88=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20mock=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9D=84=20verifyNoInteractions=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../loopers/application/ranking/RankingAssemblerTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java index 2e4fed602e..72e50e5019 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java @@ -16,8 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @DisplayName("RankingAssembler 단위 테스트") @@ -100,7 +99,7 @@ void emptyEntries() { assertThat(result.effectiveDate()).isEqualTo(BASE_DATE); assertThat(result.total()).isEqualTo(0L); assertThat(result.items()).isEmpty(); - verify(productFacade, never()).findVisibleByIds(List.of()); + verifyNoInteractions(productFacade); } } } From 250b6fbabdc8a80e1b91275c8647791090547eed Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Fri, 17 Apr 2026 01:22:28 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EC=A3=BC=EA=B0=84/=EC=9B=94?= =?UTF-8?q?=EA=B0=84=20=EB=9E=AD=ED=82=B9=20stale=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20Writer=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=B2=AD=ED=81=AC=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingMvCleanupListener: beforeStep에서 baseDate 행 사전 삭제 - Writer: write() 청크 누적 후 afterStep에서 replaceRanking 1회 호출 - targetDate 누락 시 NPE 대신 명시적 IllegalStateException 발생 - E2E: metrics 0건 재실행 시 stale 데이터 정리 검증 케이스 추가 - E2E: failsWithoutTargetDate에 예외 메시지 포함 검증 추가 - 단위 테스트: write() 2회 호출 시 rank 연속성 및 치환 1회 검증 Co-Authored-By: Claude Sonnet 4.6 --- .../job/monthly/MonthlyRankingJobConfig.java | 10 +++ .../step/MonthlyRankingItemWriter.java | 39 ++++++----- .../job/weekly/WeeklyRankingJobConfig.java | 10 +++ .../weekly/step/WeeklyRankingItemWriter.java | 44 ++++++------ .../listener/RankingMvCleanupListener.java | 33 +++++++++ .../job/monthly/MonthlyRankingJobE2ETest.java | 35 +++++++++- .../step/MonthlyRankingItemWriterTest.java | 67 +++++++++++++++++++ .../job/weekly/WeeklyRankingJobE2ETest.java | 35 +++++++++- .../step/WeeklyRankingItemWriterTest.java | 67 +++++++++++++++++++ 9 files changed, 295 insertions(+), 45 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/RankingMvCleanupListener.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/monthly/step/MonthlyRankingItemWriterTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/weekly/step/WeeklyRankingItemWriterTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java index dd24bedde3..4053fdb602 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java @@ -2,7 +2,9 @@ import com.loopers.batch.job.monthly.step.MonthlyRankingItemWriter; import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.RankingMvCleanupListener; import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankRepository; import com.loopers.domain.ranking.ProductMetricsAggregate; import com.loopers.config.RankingWeightsConfig.RankingWeights; import lombok.RequiredArgsConstructor; @@ -95,6 +97,7 @@ public class MonthlyRankingJobConfig { private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; private final MonthlyRankingItemWriter monthlyRankingItemWriter; + private final MvProductRankRepository mvProductRankRepository; @Bean(JOB_NAME) public Job monthlyRankingJob() { @@ -113,6 +116,11 @@ public Job monthlyRankingJob() { * * @JobScope 를 적용하여 Job 실행마다 독립적인 Step 인스턴스를 생성한다. */ + @Bean + public RankingMvCleanupListener monthlyRankingCleanupListener() { + return new RankingMvCleanupListener(mvProductRankRepository::deleteMonthlyByBaseDate); + } + @JobScope @Bean(STEP_NAME) public Step monthlyRankingStep() { @@ -121,6 +129,8 @@ public Step monthlyRankingStep() { .reader(monthlyRankingReader(null, null, null)) .writer(monthlyRankingItemWriter) .listener(stepMonitorListener) + .listener(monthlyRankingCleanupListener()) + .listener(monthlyRankingItemWriter) .build(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java index df1ad3dacb..a7caef271a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java @@ -5,6 +5,9 @@ import com.loopers.domain.ranking.MvProductRankRow; import com.loopers.domain.ranking.ProductMetricsAggregate; import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; @@ -19,39 +22,41 @@ /** * 월간 랭킹 MV 테이블 Writer. * - * Reader SQL 이 score DESC 로 정렬되어 있으므로 Chunk 의 순서가 곧 랭킹 순서다. - * rank(1, 2, 3...) 를 부여하고 DELETE + INSERT 방식으로 mv_product_rank_monthly 를 갱신한다. + * write() 는 청크 단위로 row 를 누적하고, afterStep() 에서 replaceMonthlyRanking() 을 1회 호출한다. + * 이 방식은 chunk size 가 LIMIT 보다 작아 다중 청크가 발생해도 rank 연속성과 단일 치환을 보장한다. * - * WeeklyRankingItemWriter 와 로직이 동일하며, 대상 저장소 메서드만 다르다. - * - * baseDate = targetDate - 1일 (어제). - * API 는 date 파라미터 생략 시 어제를 기준으로 조회하므로 일관성이 유지된다. + * baseDate = targetDate - 1일. API 의 기본 조회 기준일과 일치한다. */ @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) @RequiredArgsConstructor @Component -public class MonthlyRankingItemWriter implements ItemWriter { +public class MonthlyRankingItemWriter implements ItemWriter, StepExecutionListener { - // JobParameter 에서 주입. targetDate - 1일이 MV 의 base_date 가 된다. @Value("#{jobParameters['targetDate']}") private LocalDate targetDate; private final MvProductRankRepository mvProductRankRepository; + private final List accumulated = new ArrayList<>(); + private int nextRank = 1; + @Override public void write(Chunk chunk) { - LocalDate baseDate = targetDate.minusDays(1); - - List rows = new ArrayList<>(); - int rank = 1; - // SQL ORDER BY score DESC 가 이미 적용되어 있으므로 순서 = rank. - // MonthlyRankingJobConfig.TOP_N = chunk size = LIMIT 이 일치해야 단일 Chunk 가 보장된다. - // 다중 Chunk 가 되면 매 write() 호출마다 rank 가 1 부터 재시작되므로 반드시 단일 Chunk 를 유지할 것. + if (targetDate == null) { + throw new IllegalStateException("JobParameter 'targetDate' is required"); + } for (ProductMetricsAggregate item : chunk.getItems()) { - rows.add(new MvProductRankRow(item.productId(), rank++, item.score())); + accumulated.add(new MvProductRankRow(item.productId(), nextRank++, item.score())); } + } - mvProductRankRepository.replaceMonthlyRanking(baseDate, rows); + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (targetDate == null) { + return stepExecution.getExitStatus(); + } + mvProductRankRepository.replaceMonthlyRanking(targetDate.minusDays(1), accumulated); + return stepExecution.getExitStatus(); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java index b1c7e62805..e357e0de9f 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java @@ -2,7 +2,9 @@ import com.loopers.batch.job.weekly.step.WeeklyRankingItemWriter; import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.RankingMvCleanupListener; import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankRepository; import com.loopers.domain.ranking.ProductMetricsAggregate; import com.loopers.config.RankingWeightsConfig.RankingWeights; import lombok.RequiredArgsConstructor; @@ -98,6 +100,7 @@ public class WeeklyRankingJobConfig { private final StepMonitorListener stepMonitorListener; private final PlatformTransactionManager transactionManager; private final WeeklyRankingItemWriter weeklyRankingItemWriter; + private final MvProductRankRepository mvProductRankRepository; @Bean(JOB_NAME) public Job weeklyRankingJob() { @@ -116,6 +119,11 @@ public Job weeklyRankingJob() { * * @JobScope 를 적용하여 Job 실행마다 독립적인 Step 인스턴스를 생성한다. */ + @Bean + public RankingMvCleanupListener weeklyRankingCleanupListener() { + return new RankingMvCleanupListener(mvProductRankRepository::deleteWeeklyByBaseDate); + } + @JobScope @Bean(STEP_NAME) public Step weeklyRankingStep() { @@ -124,6 +132,8 @@ public Step weeklyRankingStep() { .reader(weeklyRankingReader(null, null, null)) .writer(weeklyRankingItemWriter) .listener(stepMonitorListener) + .listener(weeklyRankingCleanupListener()) + .listener(weeklyRankingItemWriter) .build(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java index 9740ef288c..60742a01f5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java @@ -5,6 +5,9 @@ import com.loopers.domain.ranking.MvProductRankRow; import com.loopers.domain.ranking.ProductMetricsAggregate; import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; @@ -19,44 +22,41 @@ /** * 주간 랭킹 MV 테이블 Writer. * - * Reader SQL 이 score DESC 로 정렬되어 있으므로 Chunk 의 순서가 곧 랭킹 순서다. - * 이 Writer 는 Chunk 순서를 기반으로 rank(1, 2, 3...) 를 부여하고 - * DELETE + INSERT 방식으로 mv_product_rank_weekly 를 갱신한다. + * write() 는 청크 단위로 row 를 누적하고, afterStep() 에서 replaceWeeklyRanking() 을 1회 호출한다. + * 이 방식은 chunk size 가 LIMIT 보다 작아 다중 청크가 발생해도 rank 연속성과 단일 치환을 보장한다. * - * baseDate 결정 규칙: - * MV 에는 "targetDate - 1일" 을 base_date 로 저장한다. - * 예: targetDate = 2026-04-10 → base_date = 2026-04-09 (어제) - * API 는 date 파라미터 생략 시 어제를 기준으로 조회하므로 일관성이 유지된다. - * - * @StepScope 로 선언하여 Job 실행마다 새 인스턴스를 생성하고 - * targetDate JobParameter 를 올바르게 바인딩한다. + * baseDate = targetDate - 1일. API 의 기본 조회 기준일과 일치한다. */ @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) @RequiredArgsConstructor @Component -public class WeeklyRankingItemWriter implements ItemWriter { +public class WeeklyRankingItemWriter implements ItemWriter, StepExecutionListener { - // JobParameter 에서 주입. targetDate - 1일이 MV 의 base_date 가 된다. @Value("#{jobParameters['targetDate']}") private LocalDate targetDate; private final MvProductRankRepository mvProductRankRepository; + private final List accumulated = new ArrayList<>(); + private int nextRank = 1; + @Override public void write(Chunk chunk) { - // API 는 어제를 기준으로 랭킹을 조회하므로 base_date = targetDate - 1 - LocalDate baseDate = targetDate.minusDays(1); - - List rows = new ArrayList<>(); - int rank = 1; - // SQL ORDER BY score DESC 가 이미 적용되어 있으므로 순서 = rank. - // WeeklyRankingJobConfig.TOP_N = chunk size = LIMIT 이 일치해야 단일 Chunk 가 보장된다. - // 다중 Chunk 가 되면 매 write() 호출마다 rank 가 1 부터 재시작되므로 반드시 단일 Chunk 를 유지할 것. + if (targetDate == null) { + throw new IllegalStateException("JobParameter 'targetDate' is required"); + } for (ProductMetricsAggregate item : chunk.getItems()) { - rows.add(new MvProductRankRow(item.productId(), rank++, item.score())); + accumulated.add(new MvProductRankRow(item.productId(), nextRank++, item.score())); } + } - mvProductRankRepository.replaceWeeklyRanking(baseDate, rows); + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (targetDate == null) { + return stepExecution.getExitStatus(); + } + mvProductRankRepository.replaceWeeklyRanking(targetDate.minusDays(1), accumulated); + return stepExecution.getExitStatus(); } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/RankingMvCleanupListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/RankingMvCleanupListener.java new file mode 100644 index 0000000000..2f8bd274f1 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/RankingMvCleanupListener.java @@ -0,0 +1,33 @@ +package com.loopers.batch.listener; + +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; + +import java.time.LocalDate; +import java.util.function.Consumer; + +/** + * Step 시작 전 MV 테이블의 해당 baseDate 데이터를 삭제하는 리스너. + * + * Reader 가 0건을 반환하면 write() 가 호출되지 않아 Writer 내 DELETE 가 실행되지 않는다. + * 이 경우 이전 실행의 stale 데이터가 MV 에 남는 문제를 방지하기 위해 + * beforeStep() 에서 항상 사전 삭제를 수행한다. + */ +public class RankingMvCleanupListener implements StepExecutionListener { + + private final Consumer deleteAction; + + public RankingMvCleanupListener(Consumer deleteAction) { + this.deleteAction = deleteAction; + } + + @Override + public void beforeStep(StepExecution stepExecution) { + LocalDate targetDate = stepExecution.getJobParameters().getLocalDate("targetDate"); + if (targetDate == null) { + throw new IllegalStateException("JobParameter 'targetDate' is required"); + } + LocalDate baseDate = targetDate.minusDays(1); + deleteAction.accept(baseDate); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java index 154d99ee61..f7bc731a15 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java @@ -44,7 +44,7 @@ void setUp() { jdbcTemplate.execute("DELETE FROM product_metrics_hourly"); } - @DisplayName("targetDate 파라미터 없이 실행하면 배치가 실패한다.") + @DisplayName("targetDate 파라미터 없이 실행하면 명시적 오류 메시지와 함께 배치가 실패한다.") @Test void failsWithoutTargetDate() throws Exception { // given @@ -54,8 +54,12 @@ void failsWithoutTargetDate() throws Exception { var jobExecution = jobLauncherTestUtils.launchJob(); // then - assertThat(jobExecution.getExitStatus().getExitCode()) - .isEqualTo(ExitStatus.FAILED.getExitCode()); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()), + () -> assertThat(jobExecution.getAllFailureExceptions()) + .anyMatch(e -> e.getMessage() != null && e.getMessage().contains("targetDate")) + ); } @DisplayName("집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다.") @@ -208,6 +212,31 @@ void aggregatesMultipleBucketHoursForSameProduct() throws Exception { ); } + @DisplayName("기존 MV 데이터 존재 + 동일 targetDate 재실행 시 metrics 0건이면 MV도 0건이 된다.") + @Test + void clearsStaleDataWhenMetricsEmpty() throws Exception { + // given — 이전 배치 실행 결과 시뮬레이션: baseDate 에 stale 데이터 직접 삽입 + LocalDate targetDate = LocalDate.of(2026, 4, 23); + LocalDate baseDate = targetDate.minusDays(1); + jdbcTemplate.update( + "INSERT INTO mv_product_rank_monthly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)", + 1L, baseDate, 1, 5.0, LocalDateTime.now() + ); + jobLauncherTestUtils.setJob(job); + + // when — metrics 없이 동일 targetDate 로 재실행 + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — stale 데이터가 제거되어 MV 에 0건 + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countByBaseDate("mv_product_rank_monthly", baseDate)).isZero() + ); + } + @DisplayName("집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다.") @Test void limitsToTop100() throws Exception { diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/monthly/step/MonthlyRankingItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/step/MonthlyRankingItemWriterTest.java new file mode 100644 index 0000000000..39678f9719 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/monthly/step/MonthlyRankingItemWriterTest.java @@ -0,0 +1,67 @@ +package com.loopers.job.monthly.step; + +import com.loopers.batch.job.monthly.step.MonthlyRankingItemWriter; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.MvProductRankRow; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.item.Chunk; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DisplayName("MonthlyRankingItemWriter 단위 테스트") +class MonthlyRankingItemWriterTest { + + private MvProductRankRepository mvProductRankRepository; + private MonthlyRankingItemWriter writer; + + @BeforeEach + void setUp() { + mvProductRankRepository = mock(MvProductRankRepository.class); + writer = new MonthlyRankingItemWriter(mvProductRankRepository); + ReflectionTestUtils.setField(writer, "targetDate", LocalDate.of(2026, 4, 11)); + } + + @Test + @DisplayName("write() 가 두 번 호출되면 rank 가 연속되고 replaceMonthlyRanking 은 afterStep() 에서 1회만 호출된다") + void multipleWriteCallsProducesContinuousRanks() throws Exception { + // given + Chunk chunk1 = new Chunk<>(List.of( + new ProductMetricsAggregate(1L, 5.0), + new ProductMetricsAggregate(2L, 4.0) + )); + Chunk chunk2 = new Chunk<>(List.of( + new ProductMetricsAggregate(3L, 3.0) + )); + + // when + writer.write(chunk1); + writer.write(chunk2); + writer.afterStep(mock(StepExecution.class)); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mvProductRankRepository, times(1)) + .replaceMonthlyRanking(org.mockito.ArgumentMatchers.eq(LocalDate.of(2026, 4, 10)), captor.capture()); + + List rows = captor.getValue(); + assertAll( + () -> assertThat(rows).hasSize(3), + () -> assertThat(rows.get(0).rank()).isEqualTo(1), + () -> assertThat(rows.get(1).rank()).isEqualTo(2), + () -> assertThat(rows.get(2).rank()).isEqualTo(3) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java index ce497d1f79..f78ca079d3 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java @@ -44,7 +44,7 @@ void setUp() { jdbcTemplate.execute("DELETE FROM product_metrics_hourly"); } - @DisplayName("targetDate 파라미터 없이 실행하면 배치가 실패한다.") + @DisplayName("targetDate 파라미터 없이 실행하면 명시적 오류 메시지와 함께 배치가 실패한다.") @Test void failsWithoutTargetDate() throws Exception { // given @@ -54,8 +54,12 @@ void failsWithoutTargetDate() throws Exception { var jobExecution = jobLauncherTestUtils.launchJob(); // then - assertThat(jobExecution.getExitStatus().getExitCode()) - .isEqualTo(ExitStatus.FAILED.getExitCode()); + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.FAILED.getExitCode()), + () -> assertThat(jobExecution.getAllFailureExceptions()) + .anyMatch(e -> e.getMessage() != null && e.getMessage().contains("targetDate")) + ); } @DisplayName("집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다.") @@ -208,6 +212,31 @@ void aggregatesMultipleBucketHoursForSameProduct() throws Exception { ); } + @DisplayName("기존 MV 데이터 존재 + 동일 targetDate 재실행 시 metrics 0건이면 MV도 0건이 된다.") + @Test + void clearsStaleDataWhenMetricsEmpty() throws Exception { + // given — 이전 배치 실행 결과 시뮬레이션: baseDate 에 stale 데이터 직접 삽입 + LocalDate targetDate = LocalDate.of(2026, 4, 23); + LocalDate baseDate = targetDate.minusDays(1); + jdbcTemplate.update( + "INSERT INTO mv_product_rank_weekly (product_id, base_date, `rank`, score, updated_at) VALUES (?, ?, ?, ?, ?)", + 1L, baseDate, 1, 5.0, LocalDateTime.now() + ); + jobLauncherTestUtils.setJob(job); + + // when — metrics 없이 동일 targetDate 로 재실행 + var jobExecution = jobLauncherTestUtils.launchJob(new JobParametersBuilder() + .addLocalDate("targetDate", targetDate) + .toJobParameters()); + + // then — stale 데이터가 제거되어 MV 에 0건 + assertAll( + () -> assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(countByBaseDate("mv_product_rank_weekly", baseDate)).isZero() + ); + } + @DisplayName("집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다.") @Test void limitsToTop100() throws Exception { diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/weekly/step/WeeklyRankingItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/step/WeeklyRankingItemWriterTest.java new file mode 100644 index 0000000000..27b0146de7 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/weekly/step/WeeklyRankingItemWriterTest.java @@ -0,0 +1,67 @@ +package com.loopers.job.weekly.step; + +import com.loopers.batch.job.weekly.step.WeeklyRankingItemWriter; +import com.loopers.domain.ranking.MvProductRankRepository; +import com.loopers.domain.ranking.MvProductRankRow; +import com.loopers.domain.ranking.ProductMetricsAggregate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.item.Chunk; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DisplayName("WeeklyRankingItemWriter 단위 테스트") +class WeeklyRankingItemWriterTest { + + private MvProductRankRepository mvProductRankRepository; + private WeeklyRankingItemWriter writer; + + @BeforeEach + void setUp() { + mvProductRankRepository = mock(MvProductRankRepository.class); + writer = new WeeklyRankingItemWriter(mvProductRankRepository); + ReflectionTestUtils.setField(writer, "targetDate", LocalDate.of(2026, 4, 11)); + } + + @Test + @DisplayName("write() 가 두 번 호출되면 rank 가 연속되고 replaceWeeklyRanking 은 afterStep() 에서 1회만 호출된다") + void multipleWriteCallsProducesContinuousRanks() throws Exception { + // given + Chunk chunk1 = new Chunk<>(List.of( + new ProductMetricsAggregate(1L, 5.0), + new ProductMetricsAggregate(2L, 4.0) + )); + Chunk chunk2 = new Chunk<>(List.of( + new ProductMetricsAggregate(3L, 3.0) + )); + + // when + writer.write(chunk1); + writer.write(chunk2); + writer.afterStep(mock(StepExecution.class)); + + // then + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(mvProductRankRepository, times(1)) + .replaceWeeklyRanking(org.mockito.ArgumentMatchers.eq(LocalDate.of(2026, 4, 10)), captor.capture()); + + List rows = captor.getValue(); + assertAll( + () -> assertThat(rows).hasSize(3), + () -> assertThat(rows.get(0).rank()).isEqualTo(1), + () -> assertThat(rows.get(1).rank()).isEqualTo(2), + () -> assertThat(rows.get(2).rank()).isEqualTo(3) + ); + } +} From 14217107ce7af49b42736d81145d61d8af50d71c Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Fri, 17 Apr 2026 01:22:36 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20MvProductRankRepository=20null=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B0=A9=EC=96=B4=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인터페이스 Javadoc에 null 불가 및 빈 리스트 시 DELETE만 실행됨을 명시 - 구현체 4개 메서드에 Objects.requireNonNull 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../ranking/MvProductRankRepository.java | 27 ++++++++++++++++--- .../ranking/JdbcMvProductRankRepository.java | 20 +++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java index d0e4fc0d9c..50d588617a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankRepository.java @@ -7,22 +7,41 @@ * MV 랭킹 테이블 쓰기 전용 저장소 인터페이스 (DIP). * * 배치 Writer 는 이 인터페이스에만 의존하며, JDBC 구현 세부 사항을 알지 못한다. + * + * 공통 파라미터 계약: + * - baseDate, rows 는 null 을 허용하지 않는다. null 전달 시 IllegalArgumentException 이 발생한다. + * - rows 가 빈 리스트이면 DELETE 만 실행하고 INSERT 는 건너뛴다. + * 같은 base_date 로 재실행 시 집계 결과가 없으면 기존 MV 행이 정리된다. */ public interface MvProductRankRepository { /** * 주간 MV 테이블을 해당 baseDate 기준으로 교체한다 (DELETE + INSERT). * - * @param baseDate 기준일 (targetDate - 1일) - * @param rows rank 1 부터 순서대로 정렬된 집계 결과 + * @param baseDate 기준일 (targetDate - 1일), null 불가 + * @param rows rank 1 부터 순서대로 정렬된 집계 결과, null 불가. 빈 리스트이면 DELETE 만 실행 */ void replaceWeeklyRanking(LocalDate baseDate, List rows); /** * 월간 MV 테이블을 해당 baseDate 기준으로 교체한다 (DELETE + INSERT). * - * @param baseDate 기준일 (targetDate - 1일) - * @param rows rank 1 부터 순서대로 정렬된 집계 결과 + * @param baseDate 기준일 (targetDate - 1일), null 불가 + * @param rows rank 1 부터 순서대로 정렬된 집계 결과, null 불가. 빈 리스트이면 DELETE 만 실행 */ void replaceMonthlyRanking(LocalDate baseDate, List rows); + + /** + * 주간 MV 테이블에서 해당 baseDate 행을 삭제한다. + * + * @param baseDate 삭제할 기준일, null 불가 + */ + void deleteWeeklyByBaseDate(LocalDate baseDate); + + /** + * 월간 MV 테이블에서 해당 baseDate 행을 삭제한다. + * + * @param baseDate 삭제할 기준일, null 불가 + */ + void deleteMonthlyByBaseDate(LocalDate baseDate); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java index 987d491297..f41c66bc71 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java @@ -10,6 +10,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.Objects; /** * MvProductRankRepository JDBC 구현체. @@ -47,7 +48,8 @@ public class JdbcMvProductRankRepository implements MvProductRankRepository { @Transactional @Override public void replaceWeeklyRanking(LocalDate baseDate, List rows) { - // 해당 base_date 의 기존 랭킹 전체 삭제 후 새 데이터 삽입 + Objects.requireNonNull(baseDate, "baseDate must not be null"); + Objects.requireNonNull(rows, "rows must not be null"); jdbcTemplate.update(DELETE_WEEKLY, baseDate); insertBatch(INSERT_WEEKLY, baseDate, rows); } @@ -55,10 +57,26 @@ public void replaceWeeklyRanking(LocalDate baseDate, List rows @Transactional @Override public void replaceMonthlyRanking(LocalDate baseDate, List rows) { + Objects.requireNonNull(baseDate, "baseDate must not be null"); + Objects.requireNonNull(rows, "rows must not be null"); jdbcTemplate.update(DELETE_MONTHLY, baseDate); insertBatch(INSERT_MONTHLY, baseDate, rows); } + @Transactional + @Override + public void deleteWeeklyByBaseDate(LocalDate baseDate) { + Objects.requireNonNull(baseDate, "baseDate must not be null"); + jdbcTemplate.update(DELETE_WEEKLY, baseDate); + } + + @Transactional + @Override + public void deleteMonthlyByBaseDate(LocalDate baseDate) { + Objects.requireNonNull(baseDate, "baseDate must not be null"); + jdbcTemplate.update(DELETE_MONTHLY, baseDate); + } + /** * rows 를 JDBC batchUpdate 로 한 번에 삽입한다. * From 6a1e1a3267cd7a35a7d390bf969e04b0d7d8eb5e Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Fri, 17 Apr 2026 01:22:36 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EB=B0=B0=EC=B9=98=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=A2=85=EB=A3=8C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=BA=A1=EC=B2=98=20=EC=B6=A9=EB=8F=8C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=80=EC=9E=84=EC=A1=B4=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set -e 환경에서 java 실패 시 EXIT_CODE 미캡처 문제를 && EXIT_CODE=0 || EXIT_CODE=$? 패턴으로 해결 - BATCH_TZ 환경변수(기본값 Asia/Seoul)로 targetDate 기본값을 KST 기준으로 고정 Co-Authored-By: Claude Sonnet 4.6 --- scripts/run-monthly-ranking.sh | 7 +++---- scripts/run-weekly-ranking.sh | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/run-monthly-ranking.sh b/scripts/run-monthly-ranking.sh index 383d8afbfc..4925dbad94 100755 --- a/scripts/run-monthly-ranking.sh +++ b/scripts/run-monthly-ranking.sh @@ -23,16 +23,15 @@ set -euo pipefail JAR_PATH="${JAR_PATH:-apps/commerce-batch/build/libs/commerce-batch.jar}" SPRING_PROFILE="${SPRING_PROFILE:-prd}" -TARGET_DATE="${1:-$(date +%Y-%m-%d)}" +BATCH_TZ="${BATCH_TZ:-Asia/Seoul}" +TARGET_DATE="${1:-$(TZ="${BATCH_TZ}" date +%Y-%m-%d)}" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting monthlyRankingJob | targetDate=${TARGET_DATE}" java -jar "${JAR_PATH}" \ --spring.profiles.active="${SPRING_PROFILE}" \ --job.name=monthlyRankingJob \ - targetDate="${TARGET_DATE}" - -EXIT_CODE=$? + targetDate="${TARGET_DATE}" && EXIT_CODE=0 || EXIT_CODE=$? echo "[$(date '+%Y-%m-%d %H:%M:%S')] monthlyRankingJob finished | exitCode=${EXIT_CODE}" exit ${EXIT_CODE} diff --git a/scripts/run-weekly-ranking.sh b/scripts/run-weekly-ranking.sh index bcd696aba2..e53f5baff5 100755 --- a/scripts/run-weekly-ranking.sh +++ b/scripts/run-weekly-ranking.sh @@ -23,16 +23,15 @@ set -euo pipefail JAR_PATH="${JAR_PATH:-apps/commerce-batch/build/libs/commerce-batch.jar}" SPRING_PROFILE="${SPRING_PROFILE:-prd}" -TARGET_DATE="${1:-$(date +%Y-%m-%d)}" +BATCH_TZ="${BATCH_TZ:-Asia/Seoul}" +TARGET_DATE="${1:-$(TZ="${BATCH_TZ}" date +%Y-%m-%d)}" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting weeklyRankingJob | targetDate=${TARGET_DATE}" java -jar "${JAR_PATH}" \ --spring.profiles.active="${SPRING_PROFILE}" \ --job.name=weeklyRankingJob \ - targetDate="${TARGET_DATE}" - -EXIT_CODE=$? + targetDate="${TARGET_DATE}" && EXIT_CODE=0 || EXIT_CODE=$? echo "[$(date '+%Y-%m-%d %H:%M:%S')] weeklyRankingJob finished | exitCode=${EXIT_CODE}" exit ${EXIT_CODE} From adca46c725e2e5cfa7b6a9913dd0aae95a857197 Mon Sep 17 00:00:00 2001 From: jsj1215 Date: Sun, 19 Apr 2026 15:19:00 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs:=2010=EC=A3=BC=EC=B0=A8=20=ED=95=99?= =?UTF-8?q?=EC=8A=B5=20=EB=AC=B8=EC=84=9C=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 Sonnet 4.6 --- docs/week10/blog.md | 487 ++++++++++++++++ docs/week10/pr.md | 272 +++++++++ docs/week10/springbatch.md | 842 ++++++++++++++++++++++++++ docs/week10/test.md | 418 +++++++++++++ docs/week10/week10.md | 1134 ++++++++++++++++++++++++++++++++++++ docs/week10/wil.md | 40 ++ 6 files changed, 3193 insertions(+) create mode 100644 docs/week10/blog.md create mode 100644 docs/week10/pr.md create mode 100644 docs/week10/springbatch.md create mode 100644 docs/week10/test.md create mode 100644 docs/week10/week10.md create mode 100644 docs/week10/wil.md diff --git a/docs/week10/blog.md b/docs/week10/blog.md new file mode 100644 index 0000000000..bcb12396e8 --- /dev/null +++ b/docs/week10/blog.md @@ -0,0 +1,487 @@ +# N건의 데이터를 1회로 - Spring Batch로 주간/월간 랭킹 집계하기 + +> TL;DR: 주간·월간 랭킹을 실시간으로 집계하면 DB 부하가 선형으로 증가한다. 배치로 하루 1회만 계산하고 집계 테이블에 저장하면, 조회는 단순 SELECT 한 줄로 끝난다. + +--- + +## 들어가며 + +Redis ZSET으로 실시간 일간 랭킹을 구현한 적이 있다. 주문이 발생할 때마다 메시지가 흘러들어오고, 그게 실시간으로 점수에 반영되는 방식이었다. 실시간으로 집계 할 수 있다는 점이 , "이걸 그냥 주간/월간에도 쓰면 안 되나?" 라는 생각이 자연스럽게 들었다. + +근데 잠깐 생각해보면 바로 문제가 보인다. + +주간 랭킹을 실시간으로 제공하려면 어떻게 해야 할까? 요청이 들어올 때마다 7일치 데이터를 전부 긁어서 GROUP BY하고 정렬해야 한다. 상품이 1,000개고 하루는 24시간 이니, 최악의 경우 주간 조회 한 번에 7 × 24 × 1,000 = 168,000개 row를 Full Scan + 집계 + 정렬해야 한다(실제로는 모든 상품이 모든 시간에 이벤트가 발생하진 않으므로 이보다 적다). 트래픽이 몰리는 순간에는 이 쿼리가 동시에 수백 번 실행될 수 있다. + +이 문제를 해결하기 위해 주간/월간 랭킹 집계에 **Spring Batch**와 **Materialized View** 패턴을 도입했다. + +--- + +## Batch란 + +Batch라는 단어는 원래 "한 묶음"이라는 뜻이다. 빵집에서 한 번에 굽는 빵 한 판, 공장에서 한 번에 처리하는 제품 묶음. 여기서 나온 개념이 **배치 처리(Batch Processing)**다. + +웹 요청-응답 방식이 "손님이 주문할 때마다 즉시 요리해주는 레스토랑"이라면, 배치 처리는 "새벽 4시에 오늘 재고를 한꺼번에 정리하는 마트 직원"에 더 가깝다. 아무도 없는 조용한 시간에, 대량의 데이터를 몰아서 처리한다. + +### 실무에서 배치가 필요한 순간들 + +- **월간 정산**: PG사 매출 데이터를 모아서 매달 1일 새벽에 정산 테이블 생성 +- **랭킹/통계**: 일간·주간·월간 인기 상품 집계 +- **데이터 정리**: 만료된 쿠폰 삭제, 오래된 로그 제거 +- **DW 적재**: 서비스 DB에서 BigQuery, Redshift 등 분석용 창고로 데이터 옮기기 + +공통점이 있다. 요청자가 없어도 실행되고, 대량의 데이터를 다루며, **정확성과 효율성이 신속성보다 중요**하다. + +--- + +## Spring Batch 핵심 구조 + +Spring Batch의 구조를 처음 보면 용어가 많아 막막하다. 핵심만 먼저 잡아보자. + +``` +Job + └─ Step + └─ Chunk (대용량 처리) + ├─ ItemReader (읽기) + ├─ ItemProcessor (가공, 선택) + └─ ItemWriter (저장) +``` + +### Job + +배치 실행의 최상위 단위다. "주간 랭킹 집계"라는 작업 전체를 묶는 설계도라고 보면 된다. 하나의 Job은 여러 Step으로 구성되고, `JobBuilder`로 만든다. + +```java +@Bean +public Job weeklyRankingJob() { + return new JobBuilder("weeklyRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) // 매 실행마다 고유 ID 부여 + .start(weeklyRankingStep()) + .listener(jobListener) + .build(); +} +``` + +`RunIdIncrementer`를 붙이는 이유가 있다. Spring Batch는 동일한 `JobParameters`로 이미 완료된 Job은 재실행을 막는다. `RunIdIncrementer`는 실행마다 `run.id`를 자동으로 증가시켜서, 같은 날짜 파라미터로도 매번 새 실행으로 인식되게 해준다. + +### Step + +Job을 구성하는 세부 단계다. 하나의 Job 안에 여러 Step이 있을 수 있고, Step들은 순서대로 실행된다. 각 Step은 독립적으로 성공/실패 상태를 갖기 때문에, 특정 Step이 실패하면 그 Step부터 재시작할 수 있다. + +### JobParameters + +Job 실행 시 외부에서 주입하는 값이다. 이번 구현에서는 `targetDate`를 파라미터로 받아 어떤 기간의 데이터를 집계할지 결정한다. + +```java +new JobParametersBuilder() + .addLocalDate("targetDate", LocalDate.of(2026, 4, 16)) + .toJobParameters(); +``` + +Step 내부에서는 `@StepScope` + `@Value("#{jobParameters['targetDate']}")`로 꺼내 쓴다. Step이 실제로 실행되는 시점에 값이 바인딩되는 지연 초기화 방식이다. + +### JobRepository + +Spring Batch가 Job 실행 이력을 DB에 자동으로 저장하는 저장소다. 개발자가 직접 건드리지 않아도 프레임워크가 알아서 `BATCH_JOB_EXECUTION`, `BATCH_STEP_EXECUTION` 등의 테이블에 기록한다. 덕분에 언제 실행됐고, 몇 건 처리됐고, 실패했다면 어디서 멈췄는지 추적할 수 있다. + +### Chunk + +Step 안에서 데이터를 N건씩 나눠 읽고-가공하고-저장하는 방식. + +### Chunk가 왜 필요할까? + +10만 건의 데이터를 처리한다고 가정해보자. 전부 메모리에 올려서 한 번에 처리하면 OOM(Out of Memory)이 날 수 있다. Chunk는 이걸 1,000건씩 나눠서 처리한다. 1,000건 읽고 → 처리하고 → 저장하고 → 다음 1,000건. 트랜잭션도 이 단위로 묶인다. + +실패 시 롤백 범위는 어디서 예외가 났느냐에 따라 다르다. Reader나 Processor에서 예외가 나면 해당 건만 문제가 되지만, **Writer에서 예외가 나면 해당 청크 전체가 롤백**된다. 이미 읽고 가공한 데이터도 저장을 못 한 채 롤백되는 것이다. 이 때문에 Spring Batch는 Skip과 Retry 정책을 제공한다. 특정 예외는 건너뛰거나(`skip`), 몇 번까지 재시도할지(`retry`) 설정해두면 일부 건이 실패해도 배치 전체가 중단되지 않고 계속 진행할 수 있다. 이번 구현은 100건 전체를 단일 Chunk로 처리하는 구조라 Writer 실패 시 전체 롤백 후 재실행하는 방식을 택했다. + +### Tasklet은 언제 써야할까? + +Chunk와 별개로 **Tasklet**이라는 방식도 있다. 단순히 "이 SQL 한 번 실행해"거나 "이 파일 삭제해" 같은 1회성 작업에 적합하다. Reader/Processor/Writer를 굳이 만들 필요 없이 `execute()` 메서드 하나에 로직을 담는다. + +```java +@Bean +public Step cleanupStep(JobRepository jobRepository, PlatformTransactionManager txManager) { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + expiredCouponRepository.deleteExpired(); + return RepeatStatus.FINISHED; + }, txManager) + .build(); +} +``` + +--- + +## 실시간 집계 vs 배치 + MV + +잠깐 짚고 넘어가면, 일간 랭킹도 API 요청 시점에 집계하는 게 아니다. Kafka 이벤트가 들어올 때마다 streamer가 Redis ZSET을 갱신하고, API는 그걸 단순히 읽어올 뿐이다. "실시간"이라는 표현은 API가 집계한다는 뜻이 아니라, ZSET이 이벤트 단위로 갱신된다는 뜻이다. 아래에서 말하는 "실시간 집계 방식"은 주간/월간 랭킹을 만약 **요청마다 DB에서 직접 계산한다면** 어떻게 될지에 대한 가상 시나리오다. + +참고로 여기서 말하는 MV(Materialized View)는 PostgreSQL이나 Oracle처럼 DB가 직접 제공하는 기능이 아니다. MySQL은 Materialized View를 내장하고 있지 않아서, 이번 구현처럼 **집계 결과를 별도 테이블에 미리 저장하는 방식**을 사용했다. 정확히는 Materialized View 패턴을 흉내 낸 **Summary Table(집계 테이블)**에 가깝다. "MV 테이블"이라고 부르는 건 이 패턴에서 온 표현이다. + +### 실시간 집계 방식 + +```sql +-- 랭킹 조회 요청마다 실행 +SELECT product_id, + LN(1 + SUM(view_count)) * ? + + LN(1 + SUM(like_count)) * ? + + LN(1 + SUM(order_amount)) * ? AS score +FROM product_metrics_hourly +WHERE bucket_hour BETWEEN :start AND :end +GROUP BY product_id +ORDER BY score DESC +LIMIT 100; +``` + +- 매 요청마다 전체 기간 데이터를 Full Scan + GROUP BY + Sort +- 주간 = 7일 × 24시간 = 168개 row/상품, 월간 = 720개 row/상품 +- **요청이 많아질수록 DB 부하가 선형으로 증가** + +### 배치 + MV 방식 + +```sql +-- 배치가 새벽에 1회 실행해 결과를 MV 테이블에 저장 +-- 랭킹 조회 요청은 단순 PK 조회만 +SELECT * FROM mv_product_rank_weekly WHERE base_date = ?; +``` + +- 집계는 배치가 새벽에 **단 1회**만 수행 +- 조회 시점에는 이미 계산된 결과를 단순 SELECT → **요청 수와 무관하게 응답 시간 일정** + +| 항목 | 실시간 집계 | 배치 + MV | +|------|-----------|----------| +| 조회 쿼리 비용 | Full Scan + GROUP BY + Sort | PK 단순 조회 | +| 동시 요청 증가 시 | DB 부하 선형 증가 | 부하 없음 | +| 응답 시간 | 데이터 양에 비례 | 항상 일정 | +| 집계 실행 횟수 | 요청마다 | 하루 1회 | +| 데이터 신선도 | 실시간 | 배치 실행 주기만큼 지연 | + +물론 단점도 있다. 배치가 새벽 2시에 실행된다면 오전 10시에 조회해도 어제 기준 랭킹을 보게 된다. 하지만 주간/월간 랭킹은 하루 단위로 변해도 사용자가 체감하는 차이가 크지 않다. **일간 랭킹은 Redis ZSET으로 실시간, 주간·월간은 배치 + MV로** 역할을 나누는 게 자연스럽다. + +``` +일간 랭킹 → Redis ZSET (실시간, 메모리 기반) +주간 랭킹 → MV 테이블 (배치 집계, 하루 1회 갱신) +월간 랭킹 → MV 테이블 (배치 집계, 하루 1회 갱신) +``` + +--- + +## 주간/월간 랭킹 집계를 구현하며 고민했던 선택들 + +### 선택 1. Chunk vs Tasklet — 어떤 방식으로 집계할까 + +Tasklet 안에서 직접 페이징 처리를 구현하는 방식도 충분히 가능하다. 사실 구현 초반에 그쪽이 더 자유롭고 직관적이라고 생각했다. + +그런데 이번 집계 흐름을 다시 보면: + +``` +product_metrics_hourly에서 기간 범위 읽기 +→ product_id 별 집계 (SUM) +→ 점수 계산 후 TOP 100 정렬 +→ mv_product_rank_weekly 저장 +``` + +Spring Batch Chunk의 기본 전제는 "1건 읽기 → 1건 가공 → N건 쓰기"다. 집계(GROUP BY)가 포함되면 여러 시간 버킷 row → 1개 product_id 집계값이 돼서 구조가 어색해진다. + +그렇다면, **SQL 단에서 GROUP BY 집계까지 처리해버리면 어떨까?** + +```sql +SELECT product_id, + LN(1 + SUM(view_count)) * ? + + LN(1 + SUM(like_count)) * ? + + LN(1 + SUM(order_amount)) * ? AS score +FROM product_metrics_hourly +WHERE bucket_hour >= ? AND bucket_hour < ? +GROUP BY product_id +ORDER BY score DESC +LIMIT 100 +``` + +Reader가 이미 집계된 결과(product_id 1건 = 1 row)를 읽으므로 Chunk 구조에 딱 맞아떨어진다. Spring Batch가 제공하는 페이징, 재시작, 건수 추적 혜택도 그대로 받을 수 있다. + +**결정: SQL 집계 + Spring Batch Chunk 방식** + +> 이 방식은 DB가 GROUP BY 집계를 담당하므로 상품 수가 수천~수만 개 수준일 때 효율적이다. 단, 상품이 10만 개를 넘어가고 `bucket_hour`와 `product_id` 조합에 인덱스가 없다면 GROUP BY 자체가 풀스캔이 되어 배치가 느려질 수 있다. 대규모 서비스라면 `(bucket_hour, product_id)` 복합 인덱스를 확인하거나, 일간 집계 테이블을 중간에 두는 방식을 고려해야 한다. + +### 선택 2. ItemReader 방식 — JdbcCursor vs JdbcPaging + +JDBC 기반 Reader는 크게 두 가지다. + +- **JdbcCursorItemReader**: DB 커넥션을 유지하면서 한 줄씩 스트리밍 +- **JdbcPagingItemReader**: 청크 처리마다 새 쿼리로 페이지를 가져오고 커넥션 반납 + +JdbcPagingItemReader는 실패 시 마지막 페이지부터 재시작이 가능하고 커넥션 점유 시간이 짧다는 장점이 있다. 하지만 이번 구현의 특성을 따져보면: + +- 읽는 데이터는 product_id별 집계 결과로 **최대 100건** (상품 수만큼) +- 1일 1회 실행, 실행 시간이 짧아 **재시작 필요성이 낮음** +- GROUP BY 쿼리에 페이징 키를 추가하면 **정확성 문제 발생** + +마지막 항목이 핵심이다. `JdbcPagingItemReader`는 내부적으로 정렬 키(`sortKeys`)를 기반으로 `WHERE sort_key > :lastValue` 조건을 자동으로 붙여서 페이지를 넘긴다. 이 때 정렬 키는 **유니크**해야 한다. 그런데 이번 집계 결과의 정렬 기준인 `score`는 여러 상품이 동일한 값을 가질 수 있다. score가 중복되면 페이지 경계에서 같은 row가 두 번 읽히거나 아예 누락되는 문제가 생긴다. 단순히 쿼리가 복잡해진다는 수준이 아니라, 랭킹 결과 자체가 틀려질 수 있는 **정확성 문제**다. + +**결정: JdbcCursorItemReader** + +> 이 선택은 결과가 최대 100건(`LIMIT 100`)이라는 전제 위에 있다. Cursor 방식은 DB 커넥션을 Step 전체 실행 동안 점유하기 때문에, 수만 건 이상을 스트리밍하면 배치 실행 시간이 길어지면서 네트워크 타임아웃이나 커넥션 풀 고갈 위험이 생긴다. 만약 TOP 100이 아니라 전체 상품 랭킹을 뽑아야 하는 요구사항으로 바뀐다면 `JdbcPagingItemReader`로 교체를 검토해야 한다. + +```java +@StepScope +@Bean("weeklyRankingReader") +public JdbcCursorItemReader weeklyRankingReader( + DataSource dataSource, + RankingWeights rankingWeights, + @Value("#{jobParameters['targetDate']}") LocalDate targetDate +) { + LocalDateTime startTime = targetDate.minusDays(7).atStartOfDay(); + LocalDateTime endTime = targetDate.atStartOfDay(); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyRankingReader") + .dataSource(dataSource) + .sql(AGGREGATION_SQL) + .preparedStatementSetter(ps -> { + ps.setDouble(1, rankingWeights.view()); + ps.setDouble(2, rankingWeights.like()); + ps.setDouble(3, rankingWeights.order()); + ps.setObject(4, startTime); + ps.setObject(5, endTime); + }) + .rowMapper((rs, rowNum) -> new ProductMetricsAggregate( + rs.getLong("product_id"), + rs.getDouble("score") + )) + .build(); +} +``` + +### 선택 3. 슬라이딩 윈도우 vs ISO 주차 + +"주간 베스트"를 어떻게 정의하느냐의 문제다. + +| 기준 | 설명 | 예시 (기준일: 2024-01-10) | +|------|------|--------------------------| +| 슬라이딩 윈도우 | 기준일 직전 7일 | 01-04 ~ 01-10 | +| ISO 주차 | 해당 주 월~일 | 01-08 ~ 01-14 | + +ISO 주차 방식은 과거 스냅샷 조회나 BI 리포트에 강점이 있다. 하지만 이번 목적은 **사용자에게 지금 시점 기준의 인기 상품을 보여주는 것**이다. 슬라이딩 윈도우는 어느 요일에 접속해도 항상 최근 7일 기준의 생생한 랭킹을 보여준다. + +추가로, 배치는 새벽에 실행되므로 오늘 데이터는 당일 0시~배치 실행 시각까지만 쌓인 불완전한 상태다. 그래서 **어제(targetDate - 1일) 기준 직전 7일/30일**로 잡았다. + +``` +주간: targetDate - 7일 ~ targetDate - 1일 +월간: targetDate - 30일 ~ targetDate - 1일 +``` + +**결정: 슬라이딩 윈도우 (어제 기준 직전 N일)** + +> **용어 정의**: 배치의 `targetDate`와 MV 테이블의 `base_date`, Facade의 `baseDate`는 서로 다른 값이다. +> - `targetDate`: 배치 실행 시 전달받는 파라미터. 보통 오늘 날짜. +> - `base_date` (MV 저장값): `targetDate - 1일`. 집계 범위의 마지막 날짜이자 API 조회 키. +> - Facade의 `baseDate`: API 호출 시 `date` 파라미터가 없으면 KST 기준 어제로 기본값 설정. +> +> 배치가 오늘(`targetDate`)을 받아 `base_date = 어제`로 저장하고, API는 `date` 생략 시 어제를 기준으로 조회하므로 두 값이 일치하게 된다. + +### 선택 4. MV 갱신 전략 — UPSERT vs DELETE + INSERT + +슬라이딩 윈도우 방식에서 TRUNCATE는 다른 날짜 데이터까지 지워버리므로 제외. UPSERT(INSERT ON DUPLICATE KEY UPDATE)와 DELETE + INSERT 중에서 골라야 했다. + +UPSERT가 트랜잭션 범위가 작고 원자적 연산이라 처음엔 끌렸다. 그런데 이런 케이스를 생각해보면 문제가 생긴다. + +1. 1차 실행 도중 실패 → 상품 A가 부분 적재된 상태 +2. 2차 재실행 시 상품 A가 TOP 100 밖으로 밀림 +3. 1차에서 들어간 상품 A 레코드가 그대로 잔류 + +결국 재실행 전에 해당 base_date 데이터를 먼저 DELETE해야 하는데, 그러면 DELETE + INSERT와 동일한 구조가 된다. UPSERT의 장점이 사라지는 것이다. + +최대 100건이라 락 경합 문제도 없고, 코드가 단순하고 멱등성이 보장된다. + +**결정: DELETE + INSERT (트랜잭션으로 묶어 원자적 처리)** + +> **실무 팁**: 전체 테이블을 한 번에 교체하는 경우라면 `TRUNCATE`가 `DELETE`보다 훨씬 빠르다. 하지만 이번처럼 특정 `base_date`만 지워야 하는 상황에서는 `DELETE WHERE base_date = ?`가 맞다. 한편, 배치 실행 중 서비스 중단 없이 조회가 계속 가능해야 한다면 **Temp Table Swap** 전략을 고려할 수 있다. 새 집계 결과를 임시 테이블(`mv_product_rank_weekly_tmp`)에 먼저 채운 뒤, 테이블 이름을 원자적으로 교체(`RENAME TABLE`)하는 방식이다. 조회 요청이 DELETE 공백 시간에 빈 결과를 받는 일이 없어진다. 지금 구현은 100건 소규모라 공백 시간이 사실상 0에 수렴하지만, 수십만 건 규모로 커지면 의미 있는 선택지가 된다. + +```java +@Transactional +@Override +public void replaceWeeklyRanking(LocalDate baseDate, List rows) { + jdbcTemplate.update(DELETE_WEEKLY, baseDate); + insertBatch(INSERT_WEEKLY, baseDate, rows); +} +``` + +`@Transactional`을 Repository에 직접 붙인 이유도 있다. Spring Batch Chunk 트랜잭션 안에서 호출될 때는 `REQUIRED` 전파로 Chunk 트랜잭션에 포함되어 **Chunk 커밋 시점에 함께 커밋**된다. Repository 메서드가 끝나는 시점이 아니라 Chunk 단위로 커밋이 결정되므로, 이 경우 `@Transactional`은 사실상 Chunk 트랜잭션에 합류할 뿐 독립적으로 커밋하지 않는다. 반면 배치 외부에서 이 메서드를 직접 호출하면 Repository 스스로 트랜잭션을 열고 커밋한다. 즉, `@Transactional`을 붙인 주목적은 **배치 외부 호출 시에도 DELETE + INSERT의 원자성을 Repository가 직접 보장하기 위해서**다. + + +--- + +## Listener로 배치 모니터링하기 + +배치가 실행되면 Spring Batch는 `BATCH_JOB_EXECUTION`, `BATCH_STEP_EXECUTION` 등의 테이블에 실행 이력을 자동으로 기록한다. 하지만 그것만으로는 부족하다. 언제 실행됐는지, 얼마나 걸렸는지, 실패했다면 어디서 왜 실패했는지 즉시 확인하고 싶다. + +Spring Batch는 Job/Step/Chunk 각 단계에 훅을 걸 수 있는 Listener를 제공한다. + +### JobListener — 실행 시간 추적 + +```java +@BeforeJob +void beforeJob(JobExecution jobExecution) { + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); +} + +@AfterJob +void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var duration = Duration.ofMillis(System.currentTimeMillis() - startTime); + log.info("총 소요 시간: {}시간 {}분 {}초", + duration.toHours(), duration.toMinutes() % 60, duration.getSeconds() % 60); +} +``` + +`ExecutionContext`를 활용해 시작 시각을 저장하고, Job 종료 시점에 꺼내서 총 소요 시간을 계산한다. + +### StepMonitorListener — 실패 감지 및 알림 포인트 + +```java +@Override +public ExitStatus afterStep(StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + // Slack 등 외부 알림 채널 연동 포인트 + // notificationService.sendAlert(...) + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; +} +``` + +Step 실패 시 `ExitStatus.FAILED`를 명시적으로 반환해 Job 상태에 반영한다. 알림 채널 연동 포인트이기도 하다. + +### ChunkListener — 처리 건수 추적 + +```java +@AfterChunk +void afterChunk(ChunkContext chunkContext) { + log.info("청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount()); +} +``` + +청크마다 readCount/writeCount를 로깅하면 배치가 중간에 멈췄을 때 어디까지 처리됐는지 바로 알 수 있다. + +--- + +## Spring Batch 테스트는 어떻게 하나 + +배치를 구현하고 나서 "이걸 어떻게 테스트하지?"라는 질문이 생겼다. 웹 요청은 MockMvc로 가짜 HTTP를 날리면 되는데, 배치는 Job을 실제로 실행시켜야 의미 있는 검증이 된다. + +Spring Batch는 `@SpringBatchTest` 어노테이션을 제공한다. 이걸 붙이면 `JobLauncherTestUtils`가 자동으로 주입되고, 실제 Job을 실행해서 `JobExecution`을 받아볼 수 있다. + +```java +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Test + void populatesMvTableWithRanking() throws Exception { + // given + // product_metrics_hourly에 데이터 세팅 + jobLauncherTestUtils.setJob(weeklyRankingJob); + var params = new JobParametersBuilder() + .addLocalDate("targetDate", LocalDate.of(2026, 4, 16)) + .toJobParameters(); + + // when + var execution = jobLauncherTestUtils.launchJob(params); + + // then + assertThat(execution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + // mv_product_rank_weekly 테이블 직접 조회해서 rank, score 검증 + } +} +``` + +Testcontainers로 실제 MySQL을 띄우기 때문에, 테스트가 곧 운영과 동일한 DB 환경에서 돌아간다. `@TestPropertySource`로 Job 이름을 지정하면 해당 Job 빈만 활성화되어 깔끔하게 격리된다. + +### Batch E2E와 API E2E를 분리한 이유 + +처음에는 "배치 실행 → API 조회"를 하나의 테스트에서 다 검증하려고 했다. 근데 그렇게 하면 문제가 생긴다. 배치 스펙이 바뀌어서 테스트가 실패했는지, API 조회 로직이 잘못돼서 실패했는지 실패 원인이 뒤섞인다. + +그래서 두 레이어를 완전히 분리했다. + +- **Batch E2E**: `product_metrics_hourly`에 데이터를 넣고 → Job 실행 → `mv_product_rank_weekly` 테이블에 올바르게 적재됐는지 검증 +- **API E2E**: `mv_product_rank_weekly`에 직접 `jdbcTemplate.update()`로 데이터를 심고 → HTTP 요청 → 응답 JSON 검증 + +API E2E는 배치가 아예 없어도 독립적으로 실행된다. 배치가 실패한 날에도 API 테스트는 그대로 돌아가고, 반대로 배치 로직이 바뀌어도 API 테스트는 영향받지 않는다. 실패 원인이 명확하게 드러나는 구조다. + +### 인상 깊었던 테스트 케이스들 + +**`failsWithoutTargetDate`** — 배치 파라미터 없이 실행하면 어떻게 될까? + +처음 생각은 "그냥 COMPLETED가 되는 거 아닐까?" 였다. 그런데 실제로는 `targetDate`가 null이면 `JdbcCursorItemReader`가 날짜 계산을 하다가 NPE를 던지고, Spring Batch가 이를 잡아 `ExitStatus=FAILED`로 전환한다. 조용히 COMPLETED 되는 대신 명확하게 실패한다는 걸 검증하는 테스트다. 배치가 파라미터 없이 실행되는 실수를 조기에 잡아주는 안전망이다. + +향후 개선 방향으로는 `JobParametersValidator`를 구현하면 ItemReader까지 도달하기 전에 Job 레벨에서 더 명시적인 에러 메시지로 실패시킬 수 있다. + +**`replacesExistingMvOnRerun`** — 같은 날짜로 배치를 두 번 실행하면? + +DELETE + INSERT 전략에서 멱등성을 검증하는 핵심 케이스다. 1차 실행으로 MV 테이블에 데이터를 적재한 뒤, 동일 `targetDate`로 2차 실행했을 때 기존 데이터가 완전히 교체되는지를 확인한다. 재실행 시 이전 데이터가 섞이거나 잔류하지 않음을 직접 증명한다. + +**`limitsToTop100`** — 상품이 101개 이상이어도 MV에는 100건만 들어가는가? + +이 케이스가 배치 E2E에서 가장 오래 걸렸다(주간 253ms, 월간 277ms). 101개 상품 데이터를 INSERT하고 Job을 실행하기 때문에 데이터 양이 가장 많아서다. 역설적으로 이 테스트가 실제 운영에서 대량 데이터를 처리할 때의 성능 힌트를 준다. 인덱스가 없으면 이 규모에서도 속도 차이가 눈에 띄기 시작한다는 것을. + +--- + +## 스케줄링은 코드 밖에서 + +배치를 구현하면서 "그래서 이걸 언제 어떻게 실행하나?"라는 질문이 나온다. `@Scheduled`로 내부에서 트리거하는 방법이 가장 쉽지만, 이건 함정이다. + +- 분산 환경에서 **중복 실행 위험**이 있다 +- 실패해도 **모니터링/알림 기능이 없다** +- 배치 앱을 재배포하면 스케줄도 같이 영향을 받는다 + +현업에서 가장 현실적인 방법은 **Jenkins + Shell Script** 조합이다. + +``` +Jenkins Cron Trigger (매일 새벽 2시) + │ + ▼ +./scripts/run-weekly-ranking.sh + │ + ▼ +java -jar commerce-batch.jar \ + --spring.profiles.active=prd \ + --job.name=weeklyRankingJob \ + targetDate=2026-04-16 + │ + ▼ +Jenkins → exit code 로 성공/실패 판단 → Slack 알림 등 후처리 +``` + +배치 앱은 Job이 완료되면 프로세스가 종료되도록 `web-application-type: none`으로 설정한다. 스케줄링 로직은 전혀 없고, 파라미터만 받아서 실행하는 단순한 형태로 유지하는 것이 핵심이다. + +`@ConditionalOnProperty`를 활용해 `--job.name=weeklyRankingJob`을 넘기면 해당 Job 빈만 로드되도록 격리시켰다. + +```java +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@Configuration +public class WeeklyRankingJobConfig { + public static final String JOB_NAME = "weeklyRankingJob"; + // ... +} +``` + +> `spring.batch.job.name`은 Spring Boot 3 + Spring Batch 5 기준의 프로퍼티 이름이다. Spring Boot 2 + Spring Batch 4에서는 `spring.batch.job.names`(복수형)를 사용했다. 이 프로젝트는 Spring Boot 3.4.4 기반이므로 단수형이 맞다. + +--- + +## 마치며 + +Spring Batch를 처음 써보면서 솔직히 "이게 꼭 필요한가?" 라는 생각이 들었다. 실제로 우리 회사에도 배치성 작업이 있는데, Spring Batch 없이 `@Scheduled` + 비즈니스 로직 직접 호출로 돌아가고 있다. 외부 API 연동이 포함된 작업이라 Chunk 방식으로 Reader/Processor/Writer를 나누기가 어색하고, 굳이 그럴 필요도 없어 보이는 상황이었을 거다. + +이번에 직접 써보고 나서 생각이 조금 바뀌었다. Spring Batch가 진짜 빛을 발하는 건 **운영 중에 실패했을 때**인 것 같다. `@Scheduled`로 돌리는 방식은 실패하면 로그 뒤지고, 어디까지 처리됐는지 직접 확인하고, 중복 실행 안 되게 수동으로 막아야 한다. 반면 Spring Batch는 `BATCH_JOB_EXECUTION` 테이블에 어디서 실패했는지 다 남아있다. Chunk 기반 Step에서는 이전 실행이 `FAILED` 상태일 때 같은 JobParameters로 재실행하면 마지막 커밋된 Chunk 다음부터 이어서 처리할 수 있다(단, Tasklet은 중간 지점 재시작이 안 되고 처음부터 다시 실행된다). + +그러면 외부 API가 포함된 배치는 Spring Batch를 쓰면 안 되는 걸까? 꼭 그렇진 않다. `@Scheduled`로 트리거하되, 외부 API 호출 부분을 Tasklet으로 감싸면 Batch의 **실행 이력 관리** 혜택은 그대로 누릴 수 있다. 다만 Tasklet은 중간 재시작이 되지 않으므로, 재실행 시 처음부터 다시 돌아가도 문제없는 멱등한 작업에만 적합하다. 외부 API 페이지네이션처럼 대량 데이터를 순차적으로 받아 DB에 저장하는 구조라면 오히려 Chunk 기반 Reader(API 호출)/Writer(DB 저장)로 구성하는 게 재시작 안전성 측면에서 더 낫다. + +결국 Spring Batch는 "대용량 데이터 처리 프레임워크"이기 이전에, **배치 작업을 안전하게 운영하기 위한 프레임워크**라는 느낌이 더 강했다. 우리 회사 배치가 지금 당장 문제가 없더라도, 언젠가 데이터가 쌓이고 실패 재시작이 필요해지는 순간이 오면, 스프링 배치를 도입 해야 할 것 같다. diff --git a/docs/week10/pr.md b/docs/week10/pr.md new file mode 100644 index 0000000000..e73865903e --- /dev/null +++ b/docs/week10/pr.md @@ -0,0 +1,272 @@ +## Summary + +- **배경**: 일간 랭킹은 Redis ZSET으로 실시간 처리 중이었는데, 주간·월간 랭킹은 미구현 상태였습니다. 요청마다 수백만 건을 전체 스캔 + GROUP BY로 집계하면 사용자가 늘수록 DB 부하도 그대로 비례해서 커집니다. +- **목표**: Spring Batch를 써서 주간·월간 랭킹을 새벽에 한 번 미리 계산해두고, 그 결과를 별도 MV 테이블에 저장합니다. 조회할 때는 날짜 기준으로 미리 저장된 결과를 바로 꺼내기만 하면 돼서 집계 쿼리가 전혀 실행되지 않습니다. +- **결과**: `commerce-batch`에 주간·월간 랭킹 배치 Job을 구현하고, `commerce-api`에 `/api/v1/rankings/weekly`, `/api/v1/rankings/monthly` 엔드포인트를 추가했습니다. 59건 신규 테스트 ALL PASS. + + +## Context & Decision + +### 문제 정의 + +- **현재 상태**: 일간 랭킹은 Redis ZSET 기반으로 실시간 제공 중. 주간·월간은 미구현. +- **문제**: 요청마다 `product_metrics_hourly` 테이블을 전체 스캔해서 GROUP BY로 집계하면, 월간 기준으로 상품 1개당 시간 단위 row가 720개씩 쌓여 있어 상품 수 × 720 row를 매 요청마다 계산해야 합니다. 트래픽이 늘수록 DB 부하가 그대로 비례해서 증가합니다. +- **성공 기준**: 배치가 랭킹을 미리 계산해서 저장해두고, 조회 시에는 날짜 기준 단순 SELECT만 실행되면 됩니다. + +--- + +### 선택지와 결정 + +#### 선택 1. 배치 처리 구조 + +Spring Batch에서 데이터를 처리하는 방법은 크게 두 가지입니다. + +- A: **Tasklet** — 처리 로직을 자유롭게 짤 수 있지만, 재시작·실패 복구·처리 건수 추적을 전부 직접 구현해야 합니다. +- B: **Chunk** — Reader / Processor / Writer로 역할을 나누고 N건씩 묶어서 처리합니다. 재시작·건수 추적은 프레임워크가 자동으로 해줍니다. + +**결정: B — Chunk** + +Chunk는 "1건씩 읽고 1건씩 가공한다"는 구조인데, 여기서 읽어야 할 데이터는 여러 시간 단위 row를 GROUP BY로 합친 집계값입니다. 구조가 어색해 보였지만, SQL에서 GROUP BY를 미리 처리하면 Reader 입장에서는 product_id 1개 = 집계 결과 1행으로 받아볼 수 있어서 Chunk 구조와 자연스럽게 맞아떨어졌습니다. + +#### 선택 2. 데이터를 읽는 방식 (ItemReader) + +- A: **JdbcPagingItemReader** — 페이지 단위로 쿼리를 새로 날립니다. 실패해도 마지막 페이지부터 재시작할 수 있지만, GROUP BY 쿼리에 페이지 정렬 기준 컬럼을 따로 지정해야 해서 쿼리가 복잡해집니다. +- B: **JdbcCursorItemReader** — DB 커넥션 하나를 열어두고 결과를 한 줄씩 순서대로 읽어옵니다. Step이 끝날 때까지 커넥션을 계속 잡고 있다는 단점이 있지만, 쿼리가 단순합니다. + +**결정: B — JdbcCursorItemReader** + +읽어야 할 데이터가 TOP 100건뿐이고, 하루 1회 새벽에 실행하는 배치라 실행 시간이 짧습니다. 커넥션을 오래 잡고 있을 위험이 낮고, PagingItemReader의 복잡한 정렬 키 설정을 감수할 이유가 없다고 봤습니다. + +#### 선택 3. 기간 집계 기준 + +"이번 주 랭킹"을 어떻게 정의할지가 핵심이었습니다. + +- A: **고정 기간 (ISO 주차/역월)** — "2026년 16주차" 같이 고정된 기간으로 집계합니다. 과거 특정 주 랭킹을 다시 볼 수 있지만, 주나 월이 바뀌는 시점에만 데이터가 갱신됩니다. +- B: **슬라이딩 윈도우** — 기준일 기준으로 직전 7일(또는 30일)을 매일 다시 집계합니다. 윈도우가 매일 하루씩 앞으로 밀리기 때문에, 어떤 요일에 들어와도 항상 최근 데이터 기반의 랭킹을 볼 수 있습니다. + +**결정: B — 슬라이딩 윈도우 (어제 기준 직전 7일/30일)** + +배치는 새벽에 실행되는데, 오늘 날짜 기준으로 집계하면 당일 0시부터 배치 실행 시각까지만 쌓인 불완전한 데이터가 포함됩니다. 기준일을 하루 전(targetDate - 1)으로 잡으면 전날까지 완전히 쌓인 데이터만 쓰니까 항상 안정적인 랭킹이 나옵니다. + +#### 선택 4. 집계 결과를 저장하는 방식 + +배치가 새 랭킹을 계산했을 때 기존 데이터를 어떻게 교체할지 결정해야 했습니다. + +- A: **UPSERT (INSERT ON DUPLICATE KEY UPDATE)** — 같은 PK가 있으면 덮어쓰고, 없으면 새로 추가합니다. SQL 한 번으로 처리되지만, 재실행 시 이전 데이터가 남을 수 있습니다. +- B: **DELETE + INSERT** — 해당 날짜 데이터를 먼저 지우고 새로 넣습니다. 구현이 단순하고, 재실행해도 항상 깨끗한 상태에서 시작합니다. + +**결정: B — DELETE + INSERT** + +UPSERT의 문제는 재실행 시 이전 레코드가 남는다는 점입니다. 예를 들어 1차 실행 중 실패해서 상품 A가 절반만 저장된 상태에서 2차 실행을 하면, 상품 A가 이번 TOP 100 밖으로 밀렸을 때 1차 레코드가 그대로 남습니다. 재실행을 안전하게 하려면 어차피 DELETE를 앞에 붙여야 하는데, 그러면 DELETE + INSERT와 구조가 같아져서 UPSERT를 쓸 이유가 없어집니다. + +#### 선택 5. API 엔드포인트 구조 + +- A: 기존 엔드포인트에 `?period=weekly` 파라미터 추가 — 변경 범위가 작지만, 하나의 메서드 안에서 daily/weekly/monthly를 분기 처리해야 합니다. +- B: 별도 엔드포인트 (`/rankings/weekly`, `/rankings/monthly`) — 각 엔드포인트가 하나의 역할만 가집니다. + +**결정: B — 별도 엔드포인트** + +daily는 Redis, weekly/monthly는 집계 결과 테이블로 데이터 소스가 완전히 다릅니다. A 방식이면 period 값에 따라 내부 분기가 생기고, 나중에 기간별로 다른 요구사항이 붙을 때마다 복잡도가 계속 쌓입니다. 데이터 성격이 다르면 URL로 구분하는 게 맞다고 봤습니다. + +#### 선택 6. 스케줄링 방식 + +**결정: 외부 스케줄링 (Jenkins / K8s CronJob)** + +`@Scheduled`를 쓰면 서버가 여러 대 떠 있을 때 같은 Job이 동시에 실행될 수 있습니다. 배치 앱은 독립 실행 단위로 두고 스케줄 제어는 외부에 맡겼습니다. `--job.name` 파라미터로 실행할 Job을 지정하고, `@ConditionalOnProperty`로 해당 Job에 필요한 Bean만 로드되도록 해서 Job 간 간섭을 차단했습니다. + + +## Design Overview + +### 변경 범위 + +- **영향 받는 모듈**: `commerce-batch` (신규 Job), `commerce-api` (Ranking API 확장) +- **신규 추가**: + - `commerce-batch`: `WeeklyRankingJobConfig`, `MonthlyRankingJobConfig`, `WeeklyRankingItemWriter`, `MonthlyRankingItemWriter`, `JobListener`, `StepMonitorListener`, `ChunkListener`, `domain.ranking` 패키지 (`MvProductRankRepository`, `MvProductRankRow`, `ProductMetricsAggregate`), `JdbcMvProductRankRepository` + - `commerce-api`: `WeeklyRankingFacade`, `MonthlyRankingFacade`, `RankingAssembler`, `RankingPageResult`, `RankingPageQuery`, `WeeklyRankingV1Controller`, `MonthlyRankingV1Controller`, `WeeklyRankingRepository`, `MonthlyRankingRepository`, `MvProductRankWeekly`, `MvProductRankMonthly` 및 JPA 구현체 +- **수정**: 기존 `RankingFacade`와 `RankingV1Controller`도 `RankingAssembler`·`RankingPageQuery`를 쓰도록 변경. `RankingFacadeTest`는 Mock 대상이 바뀐 부분 반영. + +### 주요 컴포넌트 역할 + +#### commerce-batch + +- [`WeeklyRankingJobConfig`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java) / [`MonthlyRankingJobConfig`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java): Job·Step·Reader를 정의합니다. SQL 집계, 슬라이딩 윈도우 날짜 계산, `TOP_N` 상수로 SQL `LIMIT`과 chunk size를 같은 값으로 묶어 관리합니다. +- [`WeeklyRankingItemWriter`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java) / [`MonthlyRankingItemWriter`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java): Chunk로 받은 데이터에 순서대로 1위부터 rank를 붙이고, DELETE + INSERT로 집계 결과 테이블을 교체합니다. +- [`JobListener`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java) / [`StepMonitorListener`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java) / [`ChunkListener`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java): 실행 시작·종료 시각, 처리 건수, 실패 원인을 로그로 남깁니다. Step이 실패하면 Slack 같은 외부 채널로 알림을 보낼 수 있는 자리입니다. +- [`JdbcMvProductRankRepository`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/JdbcMvProductRankRepository.java): DELETE + INSERT를 `@Transactional`로 원자적으로 처리합니다. Batch 트랜잭션 안에서는 합류(`REQUIRED`), 외부 호출 시 자체 트랜잭션 생성. + +#### commerce-api + +- [`WeeklyRankingFacade`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/application/ranking/WeeklyRankingFacade.java) / [`MonthlyRankingFacade`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/application/ranking/MonthlyRankingFacade.java): MV 테이블 조회 후 상품 가시성 필터링과 결과 조립을 `RankingAssembler`에 위임합니다. `date=null`이면 KST 기준 어제 날짜를 기본값으로 사용합니다. +- [`RankingAssembler`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java): DB에서 꺼낸 랭킹 목록에서 삭제·숨김 상품을 걸러내고, 페이지 결과 객체로 만들어 반환합니다. 일간·주간·월간 Facade가 모두 공통으로 씁니다. +- [`RankingPageResult`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageResult.java): `effectiveDate + total + items`를 Facade가 하나로 묶어 반환하는 VO. Controller가 Facade 내부 메서드를 직접 호출하는 구조를 제거합니다. +- [`RankingPageQuery`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingPageQuery.java): 날짜 문자열 파싱, 페이지·사이즈 범위 보정 같은 공통 파라미터 처리를 한 곳에 모아뒀습니다. 세 Controller가 공유합니다. +- [`WeeklyRankingV1Controller`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java) / [`MonthlyRankingV1Controller`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java): 각각 `/api/v1/rankings/weekly`, `/api/v1/rankings/monthly` 엔드포인트를 담당합니다. + + +## 구현 기능 + +#### 1. WeeklyRankingJobConfig / MonthlyRankingJobConfig — Chunk 기반 집계 Job + +> [`WeeklyRankingJobConfig.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/WeeklyRankingJobConfig.java) | [`MonthlyRankingJobConfig.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/MonthlyRankingJobConfig.java) + +`JdbcCursorItemReader`로 `product_metrics_hourly`에서 슬라이딩 윈도우 기간의 데이터를 SQL GROUP BY로 집계해 읽습니다. `LN(1 + SUM(view_count)) * ? + LN(1 + SUM(like_count)) * ? + LN(1 + SUM(order_amount)) * ?` 공식으로 score를 계산해 일간 랭킹과 동일한 가중치 체계를 유지합니다. `TOP_N = 100` 상수로 SQL `LIMIT`과 chunk size를 묶어 단일 Chunk 전제를 코드로 명시합니다. + +--- + +#### 2. WeeklyRankingItemWriter / MonthlyRankingItemWriter — rank 부여 + MV 교체 + +> [`WeeklyRankingItemWriter.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/weekly/step/WeeklyRankingItemWriter.java) | [`MonthlyRankingItemWriter.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/job/monthly/step/MonthlyRankingItemWriter.java) + +score 내림차순으로 정렬된 집계 결과를 받아 1위부터 순서대로 rank를 붙입니다. `JdbcMvProductRankRepository.replace*()`를 호출해 해당 `base_date`의 MV 데이터를 DELETE + INSERT로 원자적으로 교체합니다. + +--- + +#### 3. JobListener / StepMonitorListener / ChunkListener — 배치 모니터링 + +> [`JobListener.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java) | [`StepMonitorListener.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java) | [`ChunkListener.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java) + +`JobListener`는 시작 시각을 `ExecutionContext`에 저장하고 종료 시 시간/분/초 단위 소요 시간을 로깅합니다. `StepMonitorListener`는 Step 실패 시 예외 메시지를 로깅하고 `ExitStatus.FAILED`를 반환합니다 (Slack 알림 연동 포인트 주석 처리). `ChunkListener`는 Chunk 완료마다 readCount/writeCount를 로깅합니다. + +--- + +#### 4. RankingAssembler — 공통 조립 파이프라인 + +> [`RankingAssembler.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingAssembler.java) + +`entries → productIds 추출 → findVisibleByIds → RankingItemInfo.of → RankingPageResult` 파이프라인을 단일 위치로 통합합니다. 가시성 필터 정책이 바뀌면 세 Facade를 모두 수정하는 Shotgun Surgery를 방지합니다. `KST` 상수도 이곳에서 관리하여 Facade 간 일관성을 보장합니다. + +--- + +#### 5. WeeklyRankingV1Controller / MonthlyRankingV1Controller — 주간/월간 랭킹 API + +> [`WeeklyRankingV1Controller.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1Controller.java) | [`MonthlyRankingV1Controller.java`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1Controller.java) + +| 메서드 | 엔드포인트 | 설명 | +|--------|-----------|------| +| GET | `/api/v1/rankings/weekly?date=yyyyMMdd&page=1&size=20` | 주간 랭킹 조회 (MV 테이블) | +| GET | `/api/v1/rankings/monthly?date=yyyyMMdd&page=1&size=20` | 월간 랭킹 조회 (MV 테이블) | + +`date` 생략 시 KST 어제 날짜 기본값. `page=0`이면 1로 보정, `size` 범위 초과 시 MAX_SIZE(100)로 클램핑. 잘못된 날짜 포맷은 400 반환. + +--- + +#### 6. scripts/run-weekly-ranking.sh / run-monthly-ranking.sh — 외부 트리거 스크립트 + +> [`run-weekly-ranking.sh`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/scripts/run-weekly-ranking.sh) | [`run-monthly-ranking.sh`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/scripts/run-monthly-ranking.sh) + +Jenkins/K8s CronJob에서 호출하는 실행 스크립트입니다. `targetDate` 인자 생략 시 오늘 날짜 자동 설정. `JAR_PATH`, `SPRING_PROFILE` 환경변수로 경로·프로필 재정의 가능. `set -euo pipefail`로 오류 발생 시 즉시 종료 및 exit code 전달. + +--- + + +## Flow Diagram + +### Batch Flow (새벽 배치 실행) + +```mermaid +sequenceDiagram + autonumber + participant Jenkins + participant Batch as commerce-batch + participant DB as product_metrics_hourly + participant MV as mv_product_rank_weekly/monthly + + Jenkins->>Batch: java -jar commerce-batch.jar --job.name=weeklyRankingJob targetDate=2026-04-16 + Batch->>Batch: JobListener - Job 시작 로깅 + Batch->>Batch: StepMonitorListener - Step 시작 로깅 + Batch->>DB: JdbcCursorItemReader
SELECT GROUP BY (슬라이딩 윈도우 7일) ORDER BY score DESC LIMIT 100 + DB-->>Batch: ProductMetricsAggregate 목록 (최대 100건) + Batch->>Batch: ChunkListener - readCount/writeCount 로깅 + Batch->>MV: DELETE WHERE base_date = yesterday + Batch->>MV: INSERT rank 1~N (단일 트랜잭션) + Batch->>Batch: StepMonitorListener - Step 완료·실패 로깅 + Batch->>Batch: JobListener - 소요 시간 로깅 + Batch-->>Jenkins: exit code (성공 0 / 실패 1) + Jenkins->>Jenkins: 실패 시 Slack 알림 +``` + +### API Flow (랭킹 조회) + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Controller as WeeklyRankingV1Controller + participant Query as RankingPageQuery + participant Facade as WeeklyRankingFacade + participant Assembler as RankingAssembler + participant Repo as WeeklyRankingRepository + participant MV as mv_product_rank_weekly + + Client->>Controller: GET /api/v1/rankings/weekly?date=20260416&page=1&size=20 + Controller->>Query: RankingPageQuery.of(dateStr, page, size) + Query-->>Controller: query (파싱·보정 완료) + Controller->>Facade: getWeeklyRanking(date, page, size) + Facade->>Repo: getTopN(baseDate, page, size) + Repo->>MV: SELECT WHERE base_date = ? LIMIT ? OFFSET ? + MV-->>Repo: MvProductRankWeekly 목록 + Repo-->>Facade: List + Facade->>Assembler: assemble(baseDate, total, entries) + Assembler->>Assembler: 가시성 필터 적용 (삭제/숨김 상품 제외) + Assembler-->>Facade: RankingPageResult + Facade-->>Controller: RankingPageResult + Controller-->>Client: ApiResponse +``` + + +## 테스트 + +### 신규 테스트 요약 (59건 ALL PASS) + +| # | 테스트 클래스 | 유형 | 모듈 | 건수 | 검증 범위 | +|---|-------------|------|------|------|----------| +| 1 | [`RankingAssemblerTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingAssemblerTest.java) | Unit (Assembler) | api | 3 | happyPath, 가시성 필터, 빈 엔트리 | +| 2 | [`WeeklyRankingFacadeTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/application/ranking/WeeklyRankingFacadeTest.java) | Unit (Facade) | api | 4 | MV 엔트리 조합, 가시성 필터, date=null 시 KST 어제 날짜 사용 | +| 3 | [`MonthlyRankingFacadeTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/application/ranking/MonthlyRankingFacadeTest.java) | Unit (Facade) | api | 4 | WeeklyRankingFacadeTest와 동일 구조, 월간 Repository 대상 | +| 4 | [`WeeklyRankingV1ControllerTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/WeeklyRankingV1ControllerTest.java) | Unit (Controller) | api | 6 | 파라미터 보정(page=0→1, size=0→20, size=200→100), 잘못된 날짜 포맷 400 반환 | +| 5 | [`MonthlyRankingV1ControllerTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/MonthlyRankingV1ControllerTest.java) | Unit (Controller) | api | 6 | WeeklyRankingV1ControllerTest와 동일 구조, 월간 엔드포인트 대상 | +| 6 | [`WeeklyRankingJobE2ETest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/test/java/com/loopers/job/weekly/WeeklyRankingJobE2ETest.java) | Batch E2E | batch | 7 | Job 실행, score 내림차순 rank 적재, 슬라이딩 윈도우 경계, 재실행 시 MV 교체, TOP 100 제한 | +| 7 | [`MonthlyRankingJobE2ETest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-batch/src/test/java/com/loopers/job/monthly/MonthlyRankingJobE2ETest.java) | Batch E2E | batch | 7 | WeeklyRankingJobE2ETest와 동일 구조, 30일 윈도우 대상 | +| 8 | [`WeeklyRankingV1ApiE2ETest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/interfaces/api/WeeklyRankingV1ApiE2ETest.java) | API E2E | api | 7 | HTTP 전체 흐름, 페이지네이션, 가시성 필터, date 생략 시 어제 날짜 기본값 | +| 9 | [`MonthlyRankingV1ApiE2ETest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MonthlyRankingV1ApiE2ETest.java) | API E2E | api | 7 | WeeklyRankingV1ApiE2ETest와 동일 구조, 월간 엔드포인트 대상 | + +### 기존 테스트 변경 + +| 파일 | 변경 내용 | 이유 | +|------|-----------|------| +| [`RankingFacadeTest`](https://github.com/jsj1215/loop-pack-be-l2-vol3-java/blob/jsj1215/volume-10/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java) | Mock 대상이 `RankingAssembler`로 변경, `RankingPageResult` 반환 타입 반영 | `RankingFacade`가 직접 조합하던 로직을 `RankingAssembler`에 위임하면서 테스트 구조도 같이 변경 | + +### 테스트 전략 포인트 + +**Batch E2E와 API E2E를 분리한 이유** + +API E2E 테스트는 MV 테이블에 직접 데이터를 넣어서 배치 실행 없이 동작합니다. 배치가 실패하거나 스펙이 바뀌어도 API 테스트는 독립적으로 돌릴 수 있고, 실패 원인도 "배치 문제"인지 "API 문제"인지 바로 알 수 있습니다. + +**`excludesDataOutsideWindow` 테스트가 있는 이유** + +슬라이딩 윈도우 경계 조건(`targetDate - 7일` / `targetDate - 30일`)은 오프셋 값 하나만 잘못 써도 조용히 틀립니다. 윈도우 밖 데이터가 score가 아무리 높아도 MV에 적재되지 않는다는 걸 직접 검증해서 이 종류의 버그를 잡아냅니다. + +**`failsWithoutTargetDate`가 FAILED로 끝나는 건 의도된 동작** + +`targetDate` 없이 실행하면 `ItemReader`에서 NPE가 발생하고, Spring Batch가 이를 잡아 `ExitStatus=FAILED`로 전환합니다. 파라미터 없이 배치를 돌렸을 때 조용히 COMPLETED 되는 버그를 방지하는 역할입니다. 향후 `JobParametersValidator`를 따로 구현하면 더 명확한 오류 메시지를 남길 수 있습니다. + + +## Checklist + +| 구분 | 요건 | 충족 | +|------|------|------| +| **Spring Batch** | Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다 | O | +| **Spring Batch** | Chunk Oriented Processing (JdbcCursorItemReader + ItemWriter) 기반의 배치 처리를 구현했다 | O | +| **Spring Batch** | 집계 결과를 저장할 Materialized View 구조를 설계하고 올바르게 적재했다 | O | +| **Ranking API** | API가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다 | O | + + +## 리뷰포인트 + +### 1. rank 부여 방식과 MV 교체 시 공백 가능성 + +`WeeklyRankingItemWriter`에서 rank를 1부터 순서대로 붙이는 방식은 `write()`가 한 번만 호출될 때만 정확합니다. 현재는 `TOP_N = 100` 상수로 SQL `LIMIT`과 chunk size를 묶어 단일 Chunk를 보장하고 있지만, TOP_N이 커지거나 Chunk가 두 번으로 나뉘면 두 번째 write에서 rank가 1부터 재시작하는 버그가 조용히 생깁니다. Processor 단계에서 미리 rank를 붙이는 방식이 더 안전했을까요? + +DELETE + INSERT 전략도 같은 트랜잭션 안에서 처리되고 MVCC 덕분에 커밋 전까지 이전 데이터가 보이긴 하지만, 트랜잭션 커밋 직후 아주 짧은 순간 빈 결과가 나올 수 있습니다. 데이터가 늘어나 트랜잭션이 커질 경우 락 충돌 가능성도 있는데, 이런 상황에서 임시 테이블 rename이나 Blue-Green 방식 같은 대안이 실무에서 현실적으로 쓰이는지 궁금합니다. + +--- diff --git a/docs/week10/springbatch.md b/docs/week10/springbatch.md new file mode 100644 index 0000000000..7c5e79eb4d --- /dev/null +++ b/docs/week10/springbatch.md @@ -0,0 +1,842 @@ +# Spring Batch 핵심 개념 + +## 1. Spring Batch란? + +Spring Batch는 **대용량 데이터를 안정적으로 처리하기 위한 배치 프레임워크**다. + +일반적인 웹 요청-응답 흐름과 달리, 배치는 **정해진 시점에 대량의 데이터를 일괄 처리**하는 방식이다. + +### 핵심 특징 + +| 특징 | 설명 | +|------|------| +| **청크 기반 처리** | 데이터를 N건씩 나눠 읽고/가공하고/저장 → 메모리 효율적 | +| **실행 이력 관리** | Job 실행 상태, 시작/종료 시각을 DB에 자동 저장 | +| **재시작 가능** | 실패 시 처음부터가 아니라 실패 지점부터 재시작 | +| **Skip / Retry** | 특정 건 실패 시 건너뛰거나 재시도하는 정책 설정 가능 | +| **병렬 처리** | Step 병렬 실행, 파티셔닝으로 처리량 확장 가능 | + +### 언제 사용하는가? + +**Spring Batch가 적합한 경우:** +- 수십만 건 이상의 데이터를 주기적으로 처리해야 할 때 +- 실패 시 재시작이 필요한 중요한 데이터 처리 (정산, 통계 등) +- 처리 결과를 추적/감사해야 할 때 +- 읽기 → 가공 → 쓰기 흐름이 명확한 ETL 작업 + +**Spring Batch가 과한 경우:** +- 처리 건수가 적고 단순한 주기 작업 (→ `@Scheduled`로 충분) +- 실시간 이벤트 처리 (→ Kafka 등 메시지 큐 적합) + +--- + +## 2. Spring Batch vs Spring Scheduler + +두 개념은 **목적이 다른 독립적인 기술**이다. + +### Spring Scheduler + +**"언제 실행할 것인가"** 를 담당한다. + +```java +@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 +public void doSomething() { + // 임의의 작업 +} +``` + +- 특정 시간/주기에 메서드를 **자동 실행**시키는 트리거 +- `@EnableScheduling` + `@Scheduled` 만으로 동작 +- 단순 반복 작업에 적합 (캐시 갱신, 알림 발송 등) +- 실패 시 재시도, 재시작 등의 **운영 기능 없음** + +### Spring Batch + +**"무엇을 어떻게 처리할 것인가"** 를 담당한다. + +``` +Job + └─ Step + ├─ ItemReader (데이터 읽기) + ├─ ItemProcessor (가공) + └─ ItemWriter (저장) +``` + +- 대용량 데이터를 **청크 단위**로 처리하는 프레임워크 +- Job 실행 이력, 재시작, 재시도, Skip 등 **운영 기능 내장** +- `JobLauncher`로 실행 시점을 직접 제어 +- 실패한 지점부터 **재시작 가능** (JobRepository에 상태 저장) + +### 두 개념의 관계 + +``` +[Spring Scheduler] ──트리거──▶ [Spring Batch Job 실행] + "언제" "무엇을 어떻게" +``` + +실무에서는 **Scheduler로 Batch를 실행**하는 구조를 많이 사용한다. + +```java +@Scheduled(cron = "0 0 2 * * *") +public void runRankingJob() { + jobLauncher.run(rankingJob, new JobParameters(...)); +} +``` + +### 언제 무엇을 쓸까? + +| 상황 | 선택 | +|------|------| +| 캐시 갱신, 간단한 집계 | Scheduler만 | +| 수십만 건 데이터 처리, 실패 재시작 필요 | Batch (+ Scheduler로 트리거) | +| 외부에서 실행 시점 제어 필요 | Batch + Quartz or 별도 트리거 | + +--- + +## 3. 전체 구조 (Job → Step → Tasklet/Chunk) + +``` +Job + └── Step 1 + └── Tasklet (단순 작업) + 또는 + └── Chunk (대용량 처리) + ├── ItemReader + ├── ItemProcessor + └── ItemWriter + └── Step 2 + └── ... +``` + +--- + +## 4. Job & JobBuilder + +| | | +|--|--| +| `Job` | 배치 처리의 실행 단위 (무엇을 할지 정의된 설계도) | +| `JobBuilder` | 그 `Job`을 만드는 도구 | + +### JobBuilder를 사용하는 이유 + +**1. Job 구성 요소가 많아서** + +```java +// 생성자로 만든다면? +new Job(name, jobRepository, incrementer, steps, listeners, validator, ...); +// → 파라미터가 너무 많고, 어떤 값이 뭔지 알기 어려움 + +// 빌더 패턴 +new JobBuilder(JOB_NAME, jobRepository) + .incrementer(...) + .start(step1) + .listener(...) + .build(); +// → 뭘 설정하는지 명확하게 보임 +``` + +**2. 선택적 구성이 자연스러워서** + +`incrementer`, `listener`, `validator` 등은 필수가 아니다. 빌더 패턴은 필요한 것만 체이닝하면 되지만, 생성자는 모든 경우의 수마다 오버로딩을 만들어야 한다. + +**3. Job 내부 구현이 복잡해서** + +Spring Batch의 `Job` 구현체는 `SimpleJob`, `FlowJob` 등 여러 종류가 있다. `JobBuilder`가 내부적으로 설정에 맞는 구현체를 골라서 생성해준다. 사용자는 어떤 구현체인지 몰라도 된다. + +### 사용 예시 + +```java +new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) // 매 실행마다 고유 ID 부여 + .start(categorySyncStep()) + .listener(jobListener) + .build(); +``` + +| 요소 | 역할 | +|------|------| +| `JobRepository` | 배치 메타데이터(실행 이력, 상태)를 DB에 저장 | +| `RunIdIncrementer` | 동일 파라미터로 재실행 가능하게 run.id를 자동 증가 | +| `JobListener` | Job 시작/종료 훅 (`@BeforeJob`, `@AfterJob`) | + +--- + +## 5. Step & StepBuilder + +```java +new StepBuilder(STEP_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); +``` + +- `@JobScope` → Job 실행 단위로 빈 생성 (JobParameters 주입 가능) +- `ResourcelessTransactionManager` → Tasklet에서 DB 트랜잭션이 불필요할 때 사용 + +--- + +## 6. Tasklet vs Chunk + +### Tasklet + +```java +@StepScope // Step 실행 단위로 빈 생성 → JobParameters 주입 가능 +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") // SpEL로 파라미터 주입 + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 작업 수행 + return RepeatStatus.FINISHED; // CONTINUABLE 반환 시 반복 + } +} +``` + +**용도**: 단순 작업, 파일 이동, 알림 발송 등 1회성 처리 + +### Chunk (대용량 처리) + +```java +new StepBuilder("chunkStep", jobRepository) + .chunk(100, transactionManager) // 100건씩 처리 + .reader(itemReader) + .processor(itemProcessor) + .writer(itemWriter) + .build(); +``` + +**용도**: 수십만 건 이상의 데이터 읽기/가공/저장 + +- **reader** → 데이터 소스에서 1건씩 읽음 (null 반환 시 종료) +- **processor** → 읽은 데이터를 변환/필터링 (선택 사항) +- **writer** → chunk 단위로 묶어서 저장 + +### Tasklet 내에서 Chunk 처리 직접 구현 + +일반적으로 구현의 용이성 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing을 구현하기도 한다. + +#### 순수 Chunk 방식과 비교 + +```java +// Spring Batch 순수 Chunk 방식 +new StepBuilder("step", jobRepository) + .chunk(100, transactionManager) + .reader(itemReader) + .processor(itemProcessor) + .writer(itemWriter) + .build(); + +// Tasklet 내에서 Chunk 로직 직접 구현 +@Override +public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + List orders = orderRepository.findAll(); + + Lists.partition(orders, 100).forEach(chunk -> { + List results = chunk.stream() + .map(this::process) + .toList(); + resultRepository.saveAll(results); + }); + + return RepeatStatus.FINISHED; +} +``` + +#### Tasklet 내 Chunk 구현을 선택하는 경우 + +**1. 데이터 소스가 ItemReader로 구현하기 까다로울 때** + +외부 API 페이지네이션, 복잡한 커서 기반 쿼리 등 `ItemReader` 인터페이스에 맞추기 어려운 경우. + +```java +@Override +public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 외부 API 페이지네이션을 직접 다루는 경우 + List orders = externalApiClient.fetchAllPages(); + + Lists.partition(orders, 100).forEach(chunk -> { + List results = chunk.stream() + .map(this::process) + .toList(); + resultRepository.saveAll(results); + }); + + return RepeatStatus.FINISHED; +} +``` + +**2. 여러 테이블/소스를 조합해서 처리할 때** + +reader → processor → writer 의 1:1 흐름에 맞지 않는 복잡한 로직. + +```java +@Override +public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + // 두 테이블을 조합해서 처리 - ItemReader 하나로 표현하기 어려움 + List orders = orderRepository.findAll(); + Map productMap = productRepository.findAll() + .stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + Lists.partition(orders, 100).forEach(chunk -> { + List results = chunk.stream() + .map(order -> process(order, productMap.get(order.getProductId()))) + .toList(); + resultRepository.saveAll(results); + }); + + return RepeatStatus.FINISHED; +} +``` + +**3. 트랜잭션 경계를 직접 제어하고 싶을 때** + +Spring Batch Chunk는 chunk 단위로 트랜잭션이 자동으로 묶이는데, 더 세밀하게 제어하고 싶을 때. + +```java +@Override +public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + List orders = orderRepository.findAll(); + + Lists.partition(orders, 100).forEach(chunk -> { + transactionTemplate.execute(status -> { + // 직접 트랜잭션 경계 제어 + List results = chunk.stream() + .map(this::process) + .toList(); + resultRepository.saveAll(results); + return null; + }); + }); + + return RepeatStatus.FINISHED; +} +``` + +#### 트레이드오프 + +| | Spring Batch Chunk | Tasklet 내 Chunk 구현 | +|--|-------------------|----------------------| +| 상태 관리 | 자동 (readCount, writeCount 등) | 직접 구현 필요 | +| 실패 재시작 | chunk 단위로 재시작 가능 | 처음부터 재시작 | +| Skip/Retry | 프레임워크 지원 | 직접 구현 필요 | +| 복잡한 데이터 소스 | ItemReader 구현 필요 | 자유롭게 구현 가능 | +| 코드 복잡도 | 낮음 | 높음 | + +--- + +## 7. JobParameters + +### 역할 + +| 역할 | 설명 | +|------|------| +| 실행 식별 | 동일 파라미터 중복 실행 방지 | +| 값 주입 | 실행 시점에 외부에서 조건 전달 | +| 재실행 허용 | `RunIdIncrementer`로 항상 새 실행으로 처리 | + +### 1. Job 실행을 구분하는 식별자 + +Spring Batch는 **동일한 JobParameters로 이미 완료된 Job은 재실행을 막는다.** + +```java +jobLauncher.run(rankingJob, params("date=2024-01-01")) // 실행됨 +jobLauncher.run(rankingJob, params("date=2024-01-01")) // JobInstanceAlreadyCompleteException 발생 +jobLauncher.run(rankingJob, params("date=2024-01-02")) // 다른 파라미터 → 실행됨 +``` + +날짜를 파라미터로 넘기는 이유가 바로 이 때문이다. 매일 다른 파라미터가 되어야 매일 실행이 가능하다. + +### 2. 실행 시 외부에서 값 주입 + +같은 Job 코드를 **어떤 날짜/조건으로 실행할지 외부에서 제어**할 수 있다. + +```java +// 실행 시 파라미터 전달 +new JobParametersBuilder() + .addLocalDate("targetDate", LocalDate.of(2024, 1, 1)) + .addString("mode", "full") + .toJobParameters(); + +// Tasklet/Step 내부에서 SpEL로 수신 +@Value("#{jobParameters['targetDate']}") +private String targetDate; +``` + +### 3. RunIdIncrementer로 중복 실행 허용 + +파라미터가 동일해도 무조건 실행시키고 싶을 때는 `RunIdIncrementer`를 사용한다. + +```java +new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) // 실행마다 run.id를 자동 증가 + .start(step1) + .build(); +``` + +`run.id=1`, `run.id=2` ... 로 파라미터가 달라지므로 매번 새 실행으로 인식된다. + +--- + +## 8. Listener 구조 + +| 리스너 | 어노테이션 | 시점 | +|--------|-----------|------| +| `JobListener` | `@BeforeJob`, `@AfterJob` | Job 시작/종료 | +| `StepListener` | `@BeforeStep`, `@AfterStep` | Step 시작/종료 | +| `ChunkListener` | `@BeforeChunk`, `@AfterChunk` | 청크 처리 전/후 | + +### JobListener + +```java +@Component +public class JobMonitorListener { + + @BeforeJob + public void beforeJob(JobExecution jobExecution) { + // Job 시작 시 호출 + log.info("Job 시작: {}", jobExecution.getJobInstance().getJobName()); + } + + @AfterJob + public void afterJob(JobExecution jobExecution) { + // Job 종료 시 호출 (성공/실패 모두) + if (jobExecution.getStatus() == BatchStatus.FAILED) { + log.error("Job 실패: {}", jobExecution.getAllFailureExceptions()); + } + } +} +``` + +```java +new JobBuilder(JOB_NAME, jobRepository) + .listener(jobMonitorListener) + .start(step1) + .build(); +``` + +### StepListener + +```java +@Component +public class StepMonitorListener { + + @BeforeStep + public void beforeStep(StepExecution stepExecution) { + log.info("Step 시작: {}", stepExecution.getStepName()); + } + + @AfterStep + public ExitStatus afterStep(StepExecution stepExecution) { + log.info("Step 종료 - 읽은 건수: {}, 저장 건수: {}", + stepExecution.getReadCount(), + stepExecution.getWriteCount()); + return stepExecution.getExitStatus(); + } +} +``` + +```java +new StepBuilder(STEP_NAME, jobRepository) + .tasklet(...) + .listener(stepMonitorListener) + .build(); +``` + +### ChunkListener + +```java +@Component +public class ChunkMonitorListener { + + @BeforeChunk + public void beforeChunk(ChunkContext context) { + log.info("청크 처리 시작"); + } + + @AfterChunk + public void afterChunk(ChunkContext context) { + log.info("청크 처리 완료 - 처리 건수: {}", + context.getStepContext().getStepExecution().getWriteCount()); + } +} +``` + +--- + +## 9. 배치 메타데이터 테이블 + +### 역할 + +Spring Batch는 Job 실행 시 `JobRepository`를 통해 **실행 이력을 DB에 자동으로 기록**한다. +개발자가 직접 INSERT/UPDATE 하지 않아도 프레임워크가 알아서 처리한다. + +이 이력 덕분에 아래가 가능하다: +- **재시작**: 실패한 Job을 처음부터가 아니라 실패 지점부터 재실행 +- **중복 실행 방지**: 동일 파라미터로 이미 완료된 Job은 재실행 불가 +- **모니터링**: 언제 얼마나 걸렸는지, 몇 건 처리됐는지 추적 + +### 테이블 구조 및 저장 시점 + +``` +Job 시작 + → BATCH_JOB_INSTANCE INSERT (Job명 + 파라미터 조합, 처음 실행 시에만) + → BATCH_JOB_EXECUTION INSERT (상태: STARTED, 시작 시각 기록) + → BATCH_STEP_EXECUTION INSERT (Step 시작 시각 기록) + +Job 종료 + → BATCH_STEP_EXECUTION UPDATE (상태: COMPLETED/FAILED, 종료 시각, 처리 건수) + → BATCH_JOB_EXECUTION UPDATE (상태: COMPLETED/FAILED, 종료 시각) +``` + +| 테이블 | 저장 내용 | +|--------|----------| +| `BATCH_JOB_INSTANCE` | Job명 + 파라미터 조합 (논리적 실행 단위) | +| `BATCH_JOB_EXECUTION` | 실제 실행 이력 (상태, 시작/종료 시각) | +| `BATCH_JOB_EXECUTION_PARAMS` | 실행 시 전달된 파라미터 | +| `BATCH_STEP_EXECUTION` | Step별 실행 이력 (readCount, writeCount 등) | +| `BATCH_JOB_EXECUTION_CONTEXT` | Job 레벨 임시 데이터 저장소 (재시작 시 활용) | +| `BATCH_STEP_EXECUTION_CONTEXT` | Step 레벨 임시 데이터 저장소 (재시작 시 활용) | + +### 테이블 생성 방법 + +테이블은 **개발자가 준비**해야 한다. `initialize-schema` 설정으로 방식을 선택한다. + +```yaml +spring: + batch: + jdbc: + initialize-schema: always # 앱 시작 시 Spring Batch가 자동 생성 (local/test) + initialize-schema: never # 개발자가 미리 직접 생성 (운영) + initialize-schema: embedded # 임베디드 DB(H2 등)일 때만 자동 생성 +``` + +| 환경 | 설정 | 이유 | +|------|------|------| +| local / test | `always` | 편의상 자동 생성 | +| 운영 | `never` | DBA가 DDL 검토 후 직접 생성, 앱이 스키마를 건드리지 못하게 통제 | + +운영에서 `never`로 쓸 때는 Spring Batch jar 안에 내장된 DDL 스크립트를 꺼내 사용한다. + +``` +org/springframework/batch/core/schema-mysql.sql +org/springframework/batch/core/schema-postgresql.sql +``` + +--- + +## 10. 재시작(Restart) 메커니즘 + +### 재시작 흐름 + +Spring Batch는 `BATCH_STEP_EXECUTION_CONTEXT` 테이블에 **처리 위치를 자동으로 저장**하기 때문에 실패 지점을 알고 재시작할 수 있다. + +``` +1회차 실행 (실패) + → BATCH_STEP_EXECUTION_CONTEXT 에 현재 처리 위치 저장 + 예: { "FlatFileItemReader.read.count": 3500 } + ↑ 3500번째에서 실패 + +2회차 실행 (재시작) + → Spring Batch가 BATCH_JOB_EXECUTION 에서 FAILED 상태 확인 + → BATCH_STEP_EXECUTION_CONTEXT 에서 마지막 위치 읽어옴 + → 3500번째부터 이어서 처리 +``` + +### 위치를 저장하는 주체 + +**ItemReader가 청크 완료마다 자동으로 저장**한다. + +``` +청크 1 (1~100건) 처리 완료 → Context에 { count: 100 } 저장 +청크 2 (101~200건) 처리 완료 → Context에 { count: 200 } 저장 +청크 3 (201~300건) 처리 중 실패 → Context에는 { count: 200 } 남아있음 + +재시작 시 → 201번째부터 재개 +``` + +### Tasklet은 자동 저장 안 됨 + +Tasklet은 `execute()` 호출 전체가 **트랜잭션 1개**로 묶인다. 즉, **청크 1개짜리**로 동작한다. + +``` +Chunk 방식 + [청크1: 1~100건] commit → context flush + [청크2: 101~200건] commit → context flush + [청크3: 201~300건] 실패 → rollback, context는 200까지 보존 + +Tasklet 방식 + [execute() 전체: 1~10000건] 실패 → rollback, context도 같이 rollback + → 재시작 시 처음부터 +``` + +| 방식 | 실패 지점 저장 | 재시작 위치 | +|------|-------------|------------| +| Spring Batch Chunk | 자동 (청크 단위) | 마지막 완료 청크 다음부터 | +| Tasklet | 직접 구현 필요 | 기본적으로 처음부터 | + +### ExecutionContext로 위치 저장 + +`ExecutionContext`는 `BATCH_STEP_EXECUTION_CONTEXT` 테이블과 연결된 **Key-Value 저장소**다. 청크 커밋 시점에 자동으로 DB에 flush된다. + +```java +context.putInt("key", 1); +context.putLong("key", 1L); +context.putString("key", "value"); +context.put("key", serializableObject); // 직렬화 가능한 객체도 가능 +``` + +단순히 `ExecutionContext`에 저장한다고 해서 Tasklet에서 위치가 보존되지는 않는다. Tasklet 전체가 하나의 트랜잭션이므로 실패 시 context 저장도 같이 롤백되기 때문이다. + +**실제로 위치를 보존하려면 내부에서 직접 트랜잭션을 나눠야 한다.** + +```java +@Override +public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + ExecutionContext context = chunkContext.getStepContext() + .getStepExecution().getExecutionContext(); + + int lastProcessed = context.getInt("lastProcessed", 0); // 이전 위치 복원 + List orders = orderRepository.findAllFromOffset(lastProcessed); + + Lists.partition(orders, 100).forEach(chunk -> { + transactionTemplate.execute(status -> { + chunk.forEach(this::process); + context.putInt("lastProcessed", lastProcessed + currentIndex); // 청크 커밋마다 위치 저장 + return null; + }); + }); + + return RepeatStatus.FINISHED; +} +``` + +이렇게 하지 않으면 Tasklet에서 `ExecutionContext` 저장은 사실상 의미가 없다. + +### 재시작 비활성화 + +재시작이 오히려 문제가 되는 경우 끌 수 있다. + +**Job 레벨 - 재시작 자체를 막기** + +```java +new JobBuilder(JOB_NAME, jobRepository) + .preventRestart() // 실패해도 재시작 불가, 재실행 시 JobRestartException 발생 + .start(step1) + .build(); +``` + +**Step 레벨 - 재시작 횟수 제한** + +```java +new StepBuilder(STEP_NAME, jobRepository) + .tasklet(...) + .startLimit(1) // 최대 1번만 실행, 초과 시 StartLimitExceededException 발생 + .build(); +``` + +**재시작을 막는 경우:** + +| 상황 | 이유 | +|------|------| +| 멱등성이 없는 작업 (외부 API 호출, 이메일 발송 등) | 재시작 시 중복 실행 위험 | +| 항상 처음부터 다시 처리해야 하는 배치 | 이전 실패 상태를 이어받으면 안 됨 | +| 실패 시 수동 개입이 필요한 경우 | 자동 재시작보다 알람 → 확인 → 수동 실행 흐름 선호 | + +--- + +## 11. 병렬 처리 전략 + +기본 동작은 청크를 순차적으로 처리하지만, Spring Batch는 4가지 병렬 처리 전략을 제공한다. + +### 기본 동작 (순차) + +``` +Step1 → Step2 → Step3 + +청크 방식도 기본은 순차: +[청크1] → [청크2] → [청크3] → ... +``` + +### 1. Multi-threaded Step - 청크를 멀티스레드로 + +```java +new StepBuilder("step", jobRepository) + .chunk(100, transactionManager) + .reader(itemReader) + .processor(itemProcessor) + .writer(itemWriter) + .taskExecutor(new SimpleAsyncTaskExecutor()) // 청크마다 별도 스레드 + .build(); +``` + +``` +[청크1] ─┐ +[청크2] ─┼─ 동시 실행 +[청크3] ─┘ +``` + +주의: ItemReader가 thread-safe 해야 함 + +### 2. Parallel Steps - Step을 병렬로 + +```java +new JobBuilder(JOB_NAME, jobRepository) + .start(splitFlow()) + .build(); + +Flow splitFlow() { + return new FlowBuilder("splitFlow") + .split(new SimpleAsyncTaskExecutor()) + .add(flow1(), flow2()) // flow1, flow2 동시 실행 + .build(); +} +``` + +``` + ┌─ Step1(상품 집계) +Job ───┤ → Step3(최종 저장) + └─ Step2(주문 집계) +``` + +### 3. Partitioning - 데이터를 나눠서 병렬로 + +```java +new StepBuilder("masterStep", jobRepository) + .partitioner("workerStep", partitioner) // 데이터를 N개 파티션으로 분할 + .step(workerStep()) + .gridSize(4) // 4개 파티션 → 4개 스레드 + .taskExecutor(executor) + .build(); +``` + +``` + ┌─ Worker(1~2500건) +Master ────┼─ Worker(2501~5000건) 동시 실행 + ├─ Worker(5001~7500건) + └─ Worker(7501~10000건) +``` + +대용량 처리에 가장 효과적인 방식 + +### 4. Remote Chunking - 처리를 다른 서버로 분산 + +``` +Master 서버: Reader만 실행 → Kafka/MQ → Worker 서버들: Processor + Writer +``` + +여러 서버에 분산 처리할 때 사용. 인프라 복잡도가 높아 잘 쓰이지 않음 + +### 정리 + +| 전략 | 병렬 단위 | 적합한 상황 | +|------|----------|------------| +| Multi-threaded Step | 청크 | 단일 서버, 처리량 향상 | +| Parallel Steps | Step | 독립적인 Step을 동시 실행 | +| Partitioning | 데이터 범위 | 대용량 데이터 분할 처리 | +| Remote Chunking | 서버 | 분산 환경 | + +--- + +## 12. Job을 여러 개로 나누는 것과 Step으로 나누는 것의 차이 + +### Job을 여러 개로 나누면 + +``` +Job1(데이터 수집) → Job2(가공) → Job3(저장) +``` + +- Job 간 실행 순서를 **외부에서 직접 제어**해야 함 (Scheduler, 스크립트 등) +- Job1 성공 여부를 확인하고 Job2를 실행하는 로직을 **개발자가 직접 구현**해야 함 +- Job 간 데이터 공유가 어려움 (별도 저장소 필요) +- 실패 시 어느 Job부터 재시작할지 **외부에서 판단**해야 함 + +### Step으로 나누면 + +``` +Job + └─ Step1(데이터 수집) + └─ Step2(가공) + └─ Step3(저장) +``` + +- 실행 순서, 성공/실패 분기, 재시작을 **Spring Batch가 자동 관리** +- Step 간 데이터를 `ExecutionContext`로 자연스럽게 공유 가능 +- 실패 시 해당 Step부터 재시작이 **프레임워크 레벨에서 보장** + +### 언제 Job을 나눌까? + +그렇다고 모든 것을 하나의 Job에 넣을 필요는 없다. + +| 상황 | 선택 | +|------|------| +| 논리적으로 하나의 작업 흐름 | 하나의 Job + 여러 Step | +| 완전히 독립적인 작업 | 별도 Job | +| 실행 주기가 다른 작업 | 별도 Job | +| 한 작업의 실패가 다른 작업에 영향 없을 때 | 별도 Job | + +예를 들어 "주문 정산"과 "상품 랭킹 집계"는 서로 관련이 없으니 별도 Job으로 나누는 게 맞고, +"랭킹 집계 → 집계 결과 저장 → 캐시 갱신"은 하나의 흐름이니 하나의 Job에 Step으로 나누는 게 맞다. + +--- + +## 13. @ConditionalOnProperty 패턴 + +하나의 배치 앱에 여러 Job을 정의하고 실행 시점에 하나만 활성화하는 패턴. + +```yaml +# application.yml +spring: + batch: + job: + name: ${job.name:NONE} +``` + +```java +// 해당 Job 이름일 때만 빈 등록 +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@Configuration +public class DemoJobConfig { ... } +``` + +```bash +# 실행 시 Job 지정 +java -jar commerce-batch.jar --job.name=demoJob +``` + +--- + +## 14. 테스트 방법 + +```java +@SpringBootTest +@SpringBatchTest // JobLauncherTestUtils, JobRepositoryTestUtils 자동 주입 +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Test + void success() throws Exception { + // given + jobLauncherTestUtils.setJob(job); + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + + // when + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } +} +``` + +- `@SpringBatchTest` → `JobLauncherTestUtils` 자동 주입 +- `jobLauncherTestUtils.launchJob(params)` → 실제 Job 실행 후 `JobExecution` 반환 +- `ExitStatus.COMPLETED / FAILED` 로 성공/실패 검증 diff --git a/docs/week10/test.md b/docs/week10/test.md new file mode 100644 index 0000000000..41bc12a34c --- /dev/null +++ b/docs/week10/test.md @@ -0,0 +1,418 @@ +# Week 10 테스트 명세 + +> Spring Batch 기반 주간/월간 랭킹 집계 시스템 구현에 대한 테스트 전략 및 케이스 정리 + +--- + +## 테스트 피라미드 구성 + +``` + /\ + /E2E\ API E2E (TestRestTemplate + Testcontainers) + /______\ Batch E2E (SpringBatchTest + Testcontainers) + / \ + / Unit \ Facade / Controller (Mock) + /______________\ +``` + +--- + +## 1. Application Layer — Facade 단위 테스트 + +> Mock을 사용하여 Repository·ProductFacade 의존성을 격리하고, Facade 의 조합 로직만 검증한다. + +### 1-1. `RankingFacadeTest` (일간, 수정) + +**대상**: `RankingFacade` — Redis ZSET 기반 일간 랭킹 조회 +**변경 이유**: `RankingPageResult` record 도입으로 반환 타입 변경 반영 + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | ZSET Top-N 결과와 상품 정보를 Aggregation하여 `RankingPageResult`로 반환 | +| `visibilityFilter` | 숨김/삭제 상품은 `items`에서 제외, 원래 rank는 유지 | +| `nullDateDefaultsToToday` | date=null이면 KST 오늘 날짜로 Redis 키 생성 | +| `emptyRanking` | ZSET이 비면 빈 리스트 반환 | +| `getDailyRank_returnsRank` | ZREVRANK 결과를 그대로 반환 | +| `getDailyRank_nullWhenAbsent` | 순위권 밖이면 null 반환 | +| `getDailyRank_nullProductId` | productId=null이면 null 반환 | +| `KST 자정 경계 — 23:59:59` | 그날 날짜 키로 조회 | +| `KST 자정 경계 — 00:00:00` | 다음 날짜 키로 조회 | +| `getDailyRanking 23:59:59 경계` | 그날 키로 getTopN 호출 | +| `getDailyRanking 00:00:00 경계` | 새 날짜 키로 getTopN 호출 | + +--- + +### 1-2. `WeeklyRankingFacadeTest` (신규) + +**대상**: `WeeklyRankingFacade` — MV 테이블 기반 주간 랭킹 조회 +**테스트 더블**: `WeeklyRankingRepository` Mock, `ProductFacade` Mock, `Clock` 고정 + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | MV 엔트리와 상품 정보를 Aggregation하여 rank 순서대로 반환 | +| `visibilityFilter` | 숨김/삭제 상품은 응답 제외, 남은 상품의 원래 rank 유지 | +| `emptyEntries` | MV 엔트리가 없으면 빈 리스트 반환 | +| `nullDateUsesYesterdayForQuery` | date=null이면 KST 어제 날짜로 Repository 조회 (배치 base_date 규칙과 일치) | + +--- + +### 1-3. `MonthlyRankingFacadeTest` (신규) + +**대상**: `MonthlyRankingFacade` — MV 테이블 기반 월간 랭킹 조회 +**테스트 더블**: `MonthlyRankingRepository` Mock, `ProductFacade` Mock, `Clock` 고정 + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | MV 엔트리와 상품 정보를 Aggregation하여 rank 순서대로 반환 | +| `visibilityFilter` | 숨김/삭제 상품은 응답 제외, 남은 상품의 원래 rank 유지 | +| `emptyEntries` | MV 엔트리가 없으면 빈 리스트 반환 | +| `nullDateUsesYesterdayForQuery` | date=null이면 KST 어제 날짜로 Repository 조회 | + +--- + +## 2. Interfaces Layer — Controller 단위 테스트 + +> `@WebMvcTest` + MockMvc로 HTTP 레이어만 격리하여 검증한다. +> Facade는 Mock 처리하여 Controller 의 파라미터 변환·보정 로직에만 집중한다. + +### 2-1. `WeeklyRankingV1ControllerTest` (신규) + +**대상**: `WeeklyRankingV1Controller` — `GET /api/v1/rankings/weekly` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | Facade 결과가 JSON 필드(`date`, `page`, `size`, `totalElements`, `items[].rank`)로 올바르게 직렬화됨 | +| `nullDatePassedToFacade` | date 파라미터 생략 시 Facade에 null이 전달되어 날짜 기본값 처리가 Facade에 위임됨 | +| `pageZeroClampedToOne` | page=0이면 1로 보정되어 Facade 호출, 응답 `page` 필드도 1 | +| `sizeClampedToMax` | size=200이면 100으로 보정되어 Facade 호출, 응답 `size` 필드도 100 | +| `sizeZeroClampedToDefault` | size=0이면 20으로 보정되어 Facade 호출, 응답 `size` 필드도 20 | +| `badDateFormat` | `2026-04-11`처럼 yyyy-MM-dd 형식이면 400 BAD_REQUEST | + +--- + +### 2-2. `MonthlyRankingV1ControllerTest` (신규) + +**대상**: `MonthlyRankingV1Controller` — `GET /api/v1/rankings/monthly` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | Facade 결과가 JSON 필드로 올바르게 직렬화됨 | +| `nullDatePassedToFacade` | date 파라미터 생략 시 Facade에 null 전달 | +| `pageZeroClampedToOne` | page=0 → 1로 보정 | +| `sizeClampedToMax` | size=200 → 100으로 보정 | +| `sizeZeroClampedToDefault` | size=0 → 20으로 보정 | +| `badDateFormat` | 잘못된 date 포맷 → 400 BAD_REQUEST | + +--- + +## 3. Batch E2E 테스트 + +> `@SpringBatchTest` + Testcontainers(MySQL)로 실제 DB에 Job을 실행하고 MV 테이블 결과를 검증한다. + +### 3-1. `WeeklyRankingJobE2ETest` (신규 + 추가) + +**대상**: `weeklyRankingJob` — 주간 랭킹 배치 +**슬라이딩 윈도우**: `[targetDate - 7일 00:00, targetDate 00:00)` +**base_date**: `targetDate - 1일` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `failsWithoutTargetDate` | targetDate JobParameter 없이 실행하면 ExitStatus=FAILED | +| `completedWithEmptyMetrics` | 집계 대상 데이터 없으면 COMPLETED, MV 테이블에 데이터 없음 | +| `populatesMvTableWithRanking` | score 내림차순으로 rank가 부여되어 MV 테이블에 적재됨 (score 공식: `LN(1+view)*0.1 + LN(1+like)*0.2 + LN(1+orderAmount)*0.7`) | +| `replacesExistingMvOnRerun` | 동일 targetDate로 재실행 시 기존 base_date 데이터가 새 랭킹으로 교체됨 | +| `excludesDataOutsideWindow` | 윈도우 시작(`targetDate-7일`)보다 이전 데이터는 집계에서 제외됨 | +| `aggregatesMultipleBucketHoursForSameProduct` | 동일 product_id의 여러 bucket_hour 데이터가 SUM으로 합산되어 score 계산됨 | +| `limitsToTop100` | 집계 대상이 101개 이상이어도 MV 테이블에는 상위 100건만 적재됨 | + +--- + +### 3-2. `MonthlyRankingJobE2ETest` (신규 + 추가) + +**대상**: `monthlyRankingJob` — 월간 랭킹 배치 +**슬라이딩 윈도우**: `[targetDate - 30일 00:00, targetDate 00:00)` +**base_date**: `targetDate - 1일` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `failsWithoutTargetDate` | targetDate JobParameter 없이 실행하면 ExitStatus=FAILED | +| `completedWithEmptyMetrics` | 집계 대상 데이터 없으면 COMPLETED, MV 테이블에 데이터 없음 | +| `populatesMvTableWithRanking` | score 내림차순으로 rank가 부여되어 MV 테이블에 적재됨 | +| `replacesExistingMvOnRerun` | 동일 targetDate로 재실행 시 기존 base_date 데이터가 새 랭킹으로 교체됨 | +| `excludesDataOutsideWindow` | 윈도우 시작(`targetDate-30일`)보다 이전 데이터는 집계에서 제외됨 | +| `aggregatesMultipleBucketHoursForSameProduct` | 동일 product_id의 여러 bucket_hour 데이터가 SUM으로 합산됨 | +| `limitsToTop100` | 집계 대상이 101개 이상이어도 MV 테이블에는 상위 100건만 적재됨 | + +--- + +## 4. API E2E 테스트 + +> `@SpringBootTest(RANDOM_PORT)` + Testcontainers + TestRestTemplate으로 HTTP 전체 흐름을 검증한다. +> MV 테이블에 직접 데이터를 seeding하여 Batch 의존성 없이 API 동작을 독립 검증한다. + +### 4-1. `WeeklyRankingV1ApiE2ETest` (신규 + 추가) + +**대상**: `GET /api/v1/rankings/weekly` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | MV 데이터와 상품 정보가 Aggregation된 페이지 응답 반환 (`rank`, `productId`, `name`, `totalElements`, `date` 검증) | +| `visibilityFilter` | displayYn=N 상품은 응답 items에서 제외됨 | +| `defaultToYesterday` | date 파라미터 생략 시 KST 어제 날짜 기준으로 조회, 응답 `date` 필드도 어제 날짜 | +| `badDateFormat` | `2026-04-09` 형식(yyyy-MM-dd)은 400 BAD_REQUEST | +| `emptyRanking` | 해당 날짜 MV 데이터 없으면 빈 items, totalElements=0 | +| `pagination` | page=2&size=2 요청 시 2페이지(3번째 상품)만 반환, totalElements는 전체 건수 유지 | +| `sizeClampedToMax` | size=200 요청 시 응답 `size` 필드가 100으로 보정됨 | + +--- + +### 4-2. `MonthlyRankingV1ApiE2ETest` (신규 + 추가) + +**대상**: `GET /api/v1/rankings/monthly` + +| 테스트 메서드 | 검증 내용 | +|---|---| +| `happyPath` | MV 데이터와 상품 정보가 Aggregation된 페이지 응답 반환 | +| `visibilityFilter` | displayYn=N 상품은 응답에서 제외됨 | +| `defaultToYesterday` | date 생략 시 KST 어제 날짜 기준 조회 | +| `badDateFormat` | 잘못된 date 포맷 → 400 BAD_REQUEST | +| `emptyRanking` | MV 데이터 없으면 빈 items, totalElements=0 | +| `pagination` | page=2&size=2 요청 시 2페이지 데이터만 반환, totalElements는 전체 건수 유지 | +| `sizeClampedToMax` | size=200 요청 시 응답 `size` 필드가 100으로 보정됨 | + +--- + +## 5. 테스트 실행 결과 + +> 실행 환경: macOS / JDK 21 / Testcontainers(MySQL 8.0) / 2026-04-16 + +### 5-1. Application Layer — Facade 단위 테스트 + +#### `RankingFacadeTest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.019s | KST 23:59:59 에는 그 날짜 키로 조회한다 | +| PASS | 0.001s | KST 00:00:00 에는 다음 날짜 키로 조회한다 | +| PASS | 0.001s | 날짜 생략 케이스 — KST 23:59:59 에는 그 날 키로 getDailyRanking 을 호출한다 | +| PASS | 0.001s | 날짜 생략 케이스 — KST 00:00:00 에는 새 날짜 키로 getDailyRanking 을 호출한다 | +| PASS | 0.001s | ZSET Top-N 과 상품 정보를 Aggregation 하여 반환 | +| PASS | 0.001s | 삭제/숨김 상품은 응답에서 제외되고 size 는 축소된다 | +| PASS | 0.001s | date 가 null 이면 KST 오늘 날짜로 조회 | +| PASS | 0.001s | ZSET 이 비어 있으면 빈 리스트 | +| PASS | 0.001s | ZREVRANK 결과를 그대로 반환 | +| PASS | 0.001s | 순위권 밖이면 null | +| PASS | 0.000s | productId 가 null 이면 null 반환 | +| **합계** | **0.029s** | **11건 / 0실패** | + +#### `WeeklyRankingFacadeTest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.016s | MV 테이블 엔트리와 상품 정보를 Aggregation 하여 반환한다. | +| PASS | 0.001s | 삭제/숨김 상품은 응답에서 제외되고 size 는 축소된다. | +| PASS | 0.001s | MV 엔트리가 없으면 빈 리스트를 반환한다. | +| PASS | 0.001s | date 가 null 이면 KST 어제 날짜 기준으로 조회한다. | +| **합계** | **0.019s** | **4건 / 0실패** | + +#### `MonthlyRankingFacadeTest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.642s | MV 테이블 엔트리와 상품 정보를 Aggregation 하여 반환한다. | +| PASS | 0.003s | 삭제/숨김 상품은 응답에서 제외되고 size 는 축소된다. | +| PASS | 0.002s | MV 엔트리가 없으면 빈 리스트를 반환한다. | +| PASS | 0.005s | date 가 null 이면 KST 어제 날짜 기준으로 조회한다. | +| **합계** | **0.653s** | **4건 / 0실패** | + +--- + +### 5-2. Interfaces Layer — Controller 단위 테스트 + +#### `WeeklyRankingV1ControllerTest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.011s | 200 — Facade 결과가 JSON으로 올바르게 직렬화된다. | +| PASS | 0.002s | date 파라미터 생략 시 Facade 에 null 이 전달되어 날짜 기본값 처리가 위임된다. | +| PASS | 0.002s | size=0 은 기본값 20으로 보정되어 Facade 에 전달된다. | +| PASS | 0.004s | 잘못된 date 포맷은 400 BAD_REQUEST 를 반환한다. | +| PASS | 0.002s | size=200 은 100으로 보정되어 Facade 에 전달된다. | +| PASS | 0.002s | page=0 은 1로 보정되어 Facade 에 전달된다. | +| **합계** | **0.027s** | **6건 / 0실패** | + +#### `MonthlyRankingV1ControllerTest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.132s | 200 — Facade 결과가 JSON으로 올바르게 직렬화된다. | +| PASS | 0.003s | date 파라미터 생략 시 Facade 에 null 이 전달되어 날짜 기본값 처리가 위임된다. | +| PASS | 0.003s | size=0 은 기본값 20으로 보정되어 Facade 에 전달된다. | +| PASS | 0.009s | 잘못된 date 포맷은 400 BAD_REQUEST 를 반환한다. | +| PASS | 0.003s | size=200 은 100으로 보정되어 Facade 에 전달된다. | +| PASS | 0.002s | page=0 은 1로 보정되어 Facade 에 전달된다. | +| **합계** | **0.157s** | **6건 / 0실패** | + +--- + +### 5-3. Batch E2E 테스트 + +#### `WeeklyRankingJobE2ETest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.099s | 집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다. | +| PASS | 0.072s | 슬라이딩 윈도우 바깥 데이터는 집계에서 제외된다. | +| PASS | 0.060s | targetDate 파라미터 없이 실행하면 배치가 실패한다. | +| PASS | 0.068s | 집계 대상 데이터가 있으면 MV 테이블에 score 내림차순으로 랭킹이 적재된다. | +| PASS | 0.075s | 동일 상품의 여러 bucket_hour 데이터가 합산되어 점수가 계산된다. | +| PASS | 0.123s | 재실행 시 기존 MV 데이터가 새 랭킹으로 교체된다. | +| PASS | 0.253s | 집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다. | +| **합계** | **0.753s** | **7건 / 0실패** | + +#### `MonthlyRankingJobE2ETest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.711s | 집계 대상 데이터가 없을 때 배치가 COMPLETED 되고 MV 테이블에 데이터가 없다. | +| PASS | 0.124s | 슬라이딩 윈도우 바깥 데이터는 집계에서 제외된다. | +| PASS | 0.078s | targetDate 파라미터 없이 실행하면 배치가 실패한다. | +| PASS | 0.086s | 집계 대상 데이터가 있으면 MV 테이블에 score 내림차순으로 랭킹이 적재됨 | +| PASS | 0.084s | 동일 상품의 여러 bucket_hour 데이터가 합산되어 점수가 계산된다. | +| PASS | 0.135s | 재실행 시 기존 MV 데이터가 새 랭킹으로 교체된다. | +| PASS | 0.277s | 집계 대상 상품이 100개를 초과하더라도 MV 테이블에는 상위 100건만 적재된다. | +| **합계** | **1.499s** | **7건 / 0실패** | + +--- + +### 5-4. API E2E 테스트 + +#### `WeeklyRankingV1ApiE2ETest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.380s | 200 — 상품 정보가 Aggregation 된 주간 랭킹 Page 를 반환한다. | +| PASS | 0.351s | 숨김/삭제 상품은 응답에서 제외된다. | +| PASS | 0.352s | date 파라미터 생략 시 어제 날짜 기준 주간 랭킹을 조회한다. | +| PASS | 0.238s | 잘못된 date 포맷은 400을 반환한다. | +| PASS | 0.391s | 랭킹 데이터가 없으면 빈 items 와 totalElements=0 을 반환한다. | +| PASS | 0.340s | page=2, size=2 요청 시 2페이지 데이터를 반환한다. | +| PASS | 0.380s | size=200 요청 시 100으로 보정된 size 가 응답에 반영된다. | +| **합계** | **2.439s** | **7건 / 0실패** | + +#### `MonthlyRankingV1ApiE2ETest` + +| 결과 | 소요시간 | 테스트명 | +|---|---|---| +| PASS | 0.832s | 200 — 상품 정보가 Aggregation 된 월간 랭킹 Page 를 반환한다. | +| PASS | 0.355s | 숨김/삭제 상품은 응답에서 제외된다. | +| PASS | 0.295s | date 파라미터 생략 시 어제 날짜 기준 월간 랭킹을 조회한다. | +| PASS | 0.358s | 잘못된 date 포맷은 400을 반환한다. | +| PASS | 0.275s | 랭킹 데이터가 없으면 빈 items 와 totalElements=0 을 반환한다. | +| PASS | 0.369s | page=2, size=2 요청 시 2페이지 데이터를 반환한다. | +| PASS | 0.239s | size=200 요청 시 100으로 보정된 size 가 응답에 반영된다. | +| **합계** | **2.729s** | **7건 / 0실패** | + +--- + +### 5-5. 전체 결과 요약 + +| 레이어 | 테스트 클래스 | 총 건수 | 성공 | 실패 | 총 소요시간 | +|---|---|---|---|---|---| +| Facade (Unit) | RankingFacadeTest | 11 | 11 | 0 | 0.029s | +| Facade (Unit) | WeeklyRankingFacadeTest | 4 | 4 | 0 | 0.019s | +| Facade (Unit) | MonthlyRankingFacadeTest | 4 | 4 | 0 | 0.653s | +| Controller (Unit) | WeeklyRankingV1ControllerTest | 6 | 6 | 0 | 0.027s | +| Controller (Unit) | MonthlyRankingV1ControllerTest | 6 | 6 | 0 | 0.157s | +| Batch E2E | WeeklyRankingJobE2ETest | 7 | 7 | 0 | 0.753s | +| Batch E2E | MonthlyRankingJobE2ETest | 7 | 7 | 0 | 1.499s | +| API E2E | WeeklyRankingV1ApiE2ETest | 7 | 7 | 0 | 2.439s | +| API E2E | MonthlyRankingV1ApiE2ETest | 7 | 7 | 0 | 2.729s | +| **합계** | | **59** | **59** | **0** | **~8.3s** | + +> 모든 59건 PASS, 실패 0건 + +--- + +## 6. 테스트 결과 인사이트 + +### 인사이트 1 — 레이어별 실행 속도 차이가 뚜렷하다 + +| 레이어 | 평균 케이스 소요 | 이유 | +|---|---|---| +| Facade Unit | ~1ms | Mock만 사용, I/O 없음 | +| Controller Unit | ~5–22ms | Spring MVC 컨텍스트 로딩 포함 | +| Batch E2E | 60–277ms | Testcontainers DB + JDBC 실행 | +| API E2E | 238–832ms | Testcontainers DB + HTTP 왕복 | + +단위 테스트는 1ms 미만으로 끝나는 반면 E2E 테스트는 100~800ms 수준이다. +피라미드 하단에 단위 테스트를 집중시켜 빠른 피드백 루프를 유지하는 설계가 측정으로 입증된다. + +### 인사이트 2 — `limitsToTop100` 이 같은 레이어에서 가장 느리다 + +- 주간: `limitsToTop100` 253ms vs 평균 ~80ms +- 월간: `limitsToTop100` 277ms vs 평균 ~100ms + +101개 product + metric 행 insert → Job 실행 → MV 카운트 검증 순서로 데이터가 가장 많아 느리다. +실제 운영에서 배치 SQL이 대량 데이터를 처리할 때 성능 병목이 발생할 수 있음을 시사하며, +인덱스(`bucket_hour`, `product_id`) 및 LIMIT 절의 중요성을 재확인한다. + +### 인사이트 3 — Batch E2E 첫 번째 케이스가 느린 이유는 Testcontainers 초기화 + +- 월간 `completedWithEmptyMetrics`: 711ms (나머지 케이스 평균 ~100ms) + +Testcontainers MySQL 컨테이너가 첫 번째 테스트에서 초기화되기 때문이다. +이후 케이스는 동일 컨텍스트를 재사용하여 80~277ms 로 안정된다. +CI 파이프라인에서 타임아웃을 설정할 때 컨테이너 워밍업 시간을 반드시 고려해야 한다. + +### 인사이트 4 — `failsWithoutTargetDate` 가 NullPointerException 경로로 실패하는 것은 의도된 동작이다 + +배치 파라미터 검증이 Spring Batch Job 레벨이 아닌 `ItemReader` 에서 발생한다. +`targetDate` 가 null 이면 `weeklyRankingReader`/`monthlyRankingReader` 가 NPE를 던지고, +Spring Batch가 이를 잡아 `ExitStatus=FAILED` 로 전환한다. +이 테스트는 **파라미터 없이 배치를 실행했을 때 조용히 COMPLETED 되는 버그**를 방지하는 안전망 역할을 한다. +향후 개선 방향으로 `JobParametersValidator` 를 별도 구현하면 더 명시적인 실패 메시지를 남길 수 있다. + +### 인사이트 5 — Controller 의 파라미터 보정 로직은 단위 테스트로 완결된다 + +`page=0 → 1`, `size=0 → 20`, `size=200 → 100` 보정은 Controller 레이어에서만 발생한다. +MockMvc 단위 테스트에서 Facade Mock의 실제 호출 인자를 `verify()` 로 검증하기 때문에, +E2E 테스트는 보정 결과만 응답 JSON 필드로 확인하면 충분하다. +관심사 분리 원칙에 따라 Controller 테스트가 보정 로직을, E2E 테스트가 전체 흐름을 각각 담당한다. + +### 인사이트 6 — API E2E에서 Batch 의존성을 분리한 설계가 유지보수성을 높인다 + +`WeeklyRankingV1ApiE2ETest` / `MonthlyRankingV1ApiE2ETest` 는 MV 테이블에 직접 `jdbcTemplate.update()` 로 seeding한다. +배치가 실패하거나 스펙이 변경되어도 API E2E는 독립적으로 실행 가능하다. +Batch E2E(적재 검증)와 API E2E(조회 검증)를 분리함으로써 각 테스트의 실패 원인이 명확하게 드러난다. + +--- + +## 7. 테스트 전략 요약 + +### 레이어별 역할 분담 + +| 레이어 | 테스트 클래스 | 테스트 더블 | 핵심 검증 대상 | +|---|---|---|---| +| Application (Facade) | `*FacadeTest` | Mock (Repository, ProductFacade) | 조합 로직, 가시성 필터, 날짜 기본값 | +| Interfaces (Controller) | `*ControllerTest` | MockMvc + MockBean (Facade) | 파라미터 파싱, 보정, JSON 직렬화 | +| Batch E2E | `*JobE2ETest` | 없음 (Testcontainers) | Job 실행, SQL 집계, MV 적재 정확성 | +| API E2E | `*ApiE2ETest` | 없음 (Testcontainers) | HTTP 전체 흐름, 페이징, 필터링 | + +### 데이터 소스별 테스트 격리 + +| 기간 | 데이터 소스 | 검증 방식 | +|---|---|---| +| 일간 (daily) | Redis ZSET | Facade Mock → Repository stub | +| 주간 (weekly) | `mv_product_rank_weekly` | Batch E2E (적재) + API E2E (조회) | +| 월간 (monthly) | `mv_product_rank_monthly` | Batch E2E (적재) + API E2E (조회) | + +### 슬라이딩 윈도우 경계 검증 근거 + +배치 SQL의 윈도우 조건은 `bucket_hour >= startTime AND bucket_hour < endTime`이다. + +- **주간**: `startTime = targetDate - 7일 00:00`, `endTime = targetDate 00:00` +- **월간**: `startTime = targetDate - 30일 00:00`, `endTime = targetDate 00:00` + +`excludesDataOutsideWindow` 테스트는 `targetDate - 8일`(주간) / `targetDate - 31일`(월간) 데이터가 +score가 아무리 높아도 MV에 적재되지 않음을 직접 검증하여 윈도우 경계 오프셋 버그를 방지한다. diff --git a/docs/week10/week10.md b/docs/week10/week10.md new file mode 100644 index 0000000000..93c07ebf0a --- /dev/null +++ b/docs/week10/week10.md @@ -0,0 +1,1134 @@ +# 학습내용 +## 🧭 루프팩 BE L2 - Round 10 + +> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다. + +그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다. +> + + + +지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다. + +이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. **Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다. + + + +- Spring Batch (Job / Step / Chunk / Tasklet) +- ItemReader / ItemProcessor / ItemWriter +- Materialized View (사전 집계) +- 실시간 처리 vs 배치 처리 + + + +## 🧮 Bacth System + + + +### 🎞️ 실무에서 자주 보는 배치 시나리오 + +- **주문 정산** + - 주문/결제/환불 데이터를 모아 매일 새벽 3시 정산 테이블 생성. + - PG사 매출/정산 금액 검증도 함께. +- **랭킹/통계 적재** + - 일간/주간/월간 인기 상품 집계 + - 카테고리별 판매량 통계 +- **데이터 정리/청소** + - 만료된 쿠폰 삭제, 오래된 로그 제거, 캐시 초기화 +- **데이터 웨어하우스(DW) 적재** + - 서비스 DB → DW(BigQuery, Redshift 등) 로 적재 후 분석 + +### ⚖️ 실시간 vs 배치 트레이드오프 + +| 항목 | 실시간 처리 | 배치 처리 | +| --- | --- | --- | +| 장점 | 즉각 반영 → UX 좋음 | 대규모 집계, 비용 효율적 | +| 단점 | 인프라 복잡, 멱등성 관리 필요 | 지연 발생, 실시간성 부족 | +| 적합 | 좋아요 수, 실시간 랭킹 | 월간 리포트, 대시보드, BI | +| 초점 | **신속성** | **정확성 & 효율성** | + +--- +# 구현과제 +## 🏗️ Spring Batch + +### 💧 **기본 구성 요소** + +- **Job** : 배치 실행 단위 (예: “일간 주문 통계 Job”) +- **Step** : Job 을 구성하는 세부 단계 + +### 📌 배치 처리 모델 + +**Chunk-Oriented Processing** + +- 데이터 읽기 (Reader) → 가공 (Processor) → 저장 (Writer) +- 청크 단위로 트랜잭션이 관리됨 → 안정적 대량 처리 + +```java +@Bean +public Step orderStatsStep( + JobRepository jobRepository, + PlatformTransactionManager txManager, + ItemReader reader, + ItemProcessor processor, + ItemWriter writer +) { + return new StepBuilder("orderStatsStep", jobRepository) + .chunk(1000, txManager) + .reader(reader) + .processor(processor) + .writer(writer) + .build(); +} +``` + +**장점** + +- 대규모 집계/정산/데이터 변환에 적합 +- 트랜잭션 단위 조절 가능 + +--- + +**Tasklet** + +- Step = 하나의 작업(Task) 실행 +- 반복 구조 없음, 단발성 작업에 적합 + +```java +@Bean +public Step cleanupStep( + JobRepository jobRepository, + PlatformTransactionManager txManager +) { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + orderRepository.deleteOldOrders(); // 만료 주문 삭제 + return RepeatStatus.FINISHED; + }, txManager) + .build(); +} +``` + +**장점** + +- 간단한 SQL 실행, 파일 이동, 캐시 초기화 등에 적합 +- Reader/Processor/Writer 필요 없는 작업에 깔끔 + +> *일반적으로 **구현의 용이성** 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing 을 구현하기도 합니다.* +> + +--- + +### 🗼 Materialized View + + + +- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조** +- MySQL 은 MV 기능이 별도로 없으므로 보통 **별도 테이블 + 배치 적재** 방식 사용 +- 주기적으로 대규모 데이터 (각 상품의 일별 일간 집계) 를 주기적으로 집계해 활용 + +```sql +CREATE TABLE product_metrics_weekly ( // 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonthWeek VARCHAR, // 예시입니다. + updated_at DATETIME +); + +CREATE TABLE product_metrics_monthly ( // 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonth VARCHAR, // 예시입니다. + updated_at DATETIME +); +``` + +--- + +### 🎯 운영 관점에서의 배치 전략 + +- **스케줄링** : Spring Scheduler, Quartz 혹은 인프라 (Cron + K8s) +- **재실행 전략** : 실패 시 부분 롤백 vs 전체 재실행 +- **병렬 Step** : 여러 Step 을 동시에 실행해 성능 향상 +- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적 + +--- + + + +| 구분 | 링크 | +| --- | --- | +| 🔍 Spring Batch | [Spring Docs - Spring Batch](https://docs.spring.io/spring-batch/reference/) | +| ⚙ Spring Boot with Spring Batch | [Baeldung - Spring Boot with Spring Batch](https://www.baeldung.com/spring-boot-spring-batch) | +| 📖 Materialized View | [AWS - What is Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) | + + + +이번 10주 동안 우리는 **단순한 CRUD를 넘어서, 실제 서비스에서 마주치는 문제들을 단계적으로 풀어왔습니다**. 현업에서 여러분들이 활약하기 위해 어떤 것들을 알면 좋을지, 문제를 접근하고 해석하는 방법, 문제에 맞는 적절한 해답을 도출하는 방법 등을 전달하려고 노력했어요. + +- **1~3주차** : 도메인 모델링, 계층 분리, 객체 협력 설계 +- **4~6주차** : 트랜잭션과 동시성, 읽기 최적화, 외부 시스템(결제 PG) 연동과 회복 탄력성 +- **7주차** : 이벤트 와 Kafka, 유량제어 +- **8주차** : 대기열 큐 +- **9주차** : 실시간 집계, 랭킹 시스템 구축 +- **10주차** : 배치와 Materialized View를 통한 대규모 집계와 조회 최적화 + +즉, **이커머스라는 시나리오를 통해 → 설계 → 동시성 → 성능 → 회복력 → 이벤트 → 확장성 → 데이터 파이프라인 → 집계** 까지, 실무에서 다루는 거의 모든 챕터를 작은 스케일로 경험해 본 셈입니다. + +하지만 여기서 끝이 아닙니다. + +- 실제 서비스는 **더 많은 데이터와 트래픽, 더 복잡한 요구사항** 속에서 움직입니다. +- 새로운 기능을 추가할 때마다, 이번 과정에서 배운 **Trade-off와 선택의 기준**이 반복해서 필요합니다. +- 이직, 프로젝트, 사이드 개발 등 어떤 길을 가더라도, 지금 경험한 **문제 정의 → 분석 → 해결** 과정은 계속해서 쓰이게 될 것이고 힘이 되어줄 겁니다. + + + +이제는 여러분이 스스로 문제를 정의하고, 배운 도구와 방법을 적용하며, 더 깊은 학습으로 나아갈 차례입니다. + +루프팩 BE L2는 끝났지만, **여러분의 성장 여정은 여기서부터가 시작**입니다. + +--- +# 📝 Round 10 Quests + +--- + +## 💻 Implementation Quest + +> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요. +이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다. +> + + + +### 📋 과제 정보 + +이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다. + +### (1) Spring Batch Job 구현 + +- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다. + - 대상 테이블 : `product_metrics` + - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요. + +### (2) Materialized View 설계 + +- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다. + - `mv_product_rank_weekly` : 주간 TOP 100 랭킹 + - `mv_product_rank_monthly` : 월간 TOP 100 랭킹 + +### (3) Ranking API 확장 + +- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다. + +--- + +## ✅ Checklist + +### 🧱 Spring Batch + +- [ ] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다. +- [ ] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다. +- [ ] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다. + +### 🧩 Ranking API + +- [ ] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다. + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 📚 Technical Writing Guide + +### ✅ 작성 기준 + +| 항목 | 설명 | +| --- | --- | +| **형식** | 블로그 | +| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 | +| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 | +| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” | + +--- + +### ✨ 좋은 톤은 이런 느낌이에요 + +> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자 +> + +| 특징 | 예시 | +| --- | --- | +| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 | +| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 | +| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… | + +### ❌ 피해야 할 스타일 + +| 예시 | 이유 | +| --- | --- | +| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 | +| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 | +| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 | + +### 🎯 Retrospective + +- 단순히 “무엇을 했다”가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다. +- “기능 구현” 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다. +- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다. + +### 담으면 좋은 내용 + +1. **전체 여정 요약** + - 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기 + - 단순 나열이 아니라, **흐름이 어떻게 연결되었는지** 를 강조 +2. **가장 큰 전환점** + - **내 기존의 사고방식이 바뀌었다** 싶은 순간 + - *예: 4주차 트랜잭션/락을 통해 단순 @Transactional 이상의 고민을 알게 된 점, 7주차 이벤트 분리를 통해 ‘확장성’에 눈을 뜬 경험* +3. **나의 Trade-off 판단** + - 실습 중 내가 내린 중요한 선택 1~2개 + - 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지 +4. **실전과의 연결** + - “이건 실제 회사/서비스에서 써먹을 수 있겠다” 싶은 포인트 + - *예: 캐시 무효화 전략, Kafka 기반 집계, Resilience4j 설정 등* + + + + + +--- + +--- + +# 🧠 구현 전 알아야 할 개념 정리 + +## 1. 현재 코드베이스 구조 파악 + +### 데이터 소스: `product_metrics_hourly` + +배치의 읽기 대상 테이블. `commerce-streamer`의 `ProductMetricsHourly` 엔티티가 매핑되어 있다. + +``` +product_metrics_hourly +├── product_id BIGINT (복합PK) +├── bucket_hour DATETIME (복합PK — 시간 단위로 절삭된 LocalDateTime) +├── view_count BIGINT +├── like_count BIGINT +├── order_count BIGINT +├── order_amount DECIMAL(19,2) +└── updated_at DATETIME +``` + +- 하루치 = `bucket_hour` 기준 `2024-01-01 00:00` ~ `2024-01-01 23:00` (24개 row) +- 주간 집계 = 7일치 bucketHour 범위 필터 +- 월간 집계 = 해당 월 전체 bucketHour 범위 필터 + +### 기존 일간 랭킹 흐름 + +``` +commerce-streamer → product_metrics_hourly (DB 적재) + → Redis ZSET (ranking:all:yyyyMMdd) 실시간 갱신 + +commerce-api → Redis ZSET 조회 → GET /api/v1/rankings?date=yyyyMMdd +``` + +### 주간/월간 랭킹 흐름 (이번에 구현) + +``` +commerce-batch → product_metrics_hourly 읽기 + → 집계 계산 + → mv_product_rank_weekly / mv_product_rank_monthly 저장 + +commerce-api → MV 테이블 조회 → GET /api/v1/rankings?date=yyyyMMdd&period=weekly +``` + +--- + +## 2. Spring Batch ItemReader 종류 + +| ItemReader | 특징 | 적합한 상황 | +|-----------|------|-----------| +| `JpaPagingItemReader` | JPA JPQL, 페이지 단위 조회 | 엔티티 매핑이 되어있고 페이징이 자연스러울 때 | +| `JdbcCursorItemReader` | JDBC 커서 기반, 스트리밍 | 단순 SQL, 대용량, 메모리 효율 중요할 때 | +| `JdbcPagingItemReader` | JDBC + 페이징 | JDBC이지만 페이지 단위 처리 원할 때 | +| `RepositoryItemReader` | Spring Data Repository 활용 | 이미 Repository가 있고 재사용하고 싶을 때 | + +--- + +## 3. 집계 점수 계산 방식 + +일간 랭킹은 `commerce-streamer`에서 `viewCount`, `likeCount`, `orderCount`, `orderAmount`에 가중치를 적용해 ZSET score를 산출한다. + +주간/월간 집계 시에도 동일한 가중치 공식을 적용하거나, 단순 합산 후 정렬하는 방식 중 선택해야 한다. + +--- + +## 4. MV 테이블 갱신 전략 + +| 전략 | 방식 | 장단점 | +|------|------|--------| +| TRUNCATE + INSERT | 전체 삭제 후 재적재 | 구현 단순, 갱신 중 조회 공백 발생 가능 | +| UPSERT | INSERT ON DUPLICATE KEY UPDATE | 공백 없음, 구현 복잡도 있음 | +| DELETE + INSERT (트랜잭션) | 트랜잭션 안에서 삭제 후 삽입 | 공백 없음, 트랜잭션 범위 주의 | + +--- + +## 5. 배치 모듈에서 엔티티 공유 문제 + +`ProductMetricsHourly`는 `commerce-streamer` 모듈에 정의되어 있다. +`commerce-batch`는 이 모듈에 의존하지 않으므로, 다음 중 하나를 선택해야 한다. + +- **방법 A**: `commerce-batch`에 동일 테이블을 가리키는 별도 읽기 전용 엔티티 정의 +- **방법 B**: JPA 대신 JDBC(`JdbcCursorItemReader`)로 직접 SQL 쿼리 + +--- + +# 🤔 구현 시 선택해야 할 사항 + +## 선택 1. Chunk vs Tasklet 내 Chunk 구현 ✅ 결정 + +| | Spring Batch Chunk | Tasklet 내 직접 구현 | +|--|-------------------|---------------------| +| 재시작 지원 | chunk 단위 재시작 가능 | 처음부터 재시작 | +| Skip/Retry | 프레임워크 지원 | 직접 구현 필요 | +| 구현 난이도 | Reader/Processor/Writer 분리 필요 | 자유롭게 구현 가능 | +| 대용량 적합성 | 높음 | 낮음 (전체 로드 위험) | + +### 고민한 내용 + +Tasklet 내에서 직접 Chunk 처리를 구현하는 방식도 고려했다. +구현이 복잡할수록 Reader/Processor/Writer로 강제 분리하는 것보다 Tasklet 내에서 자유롭게 처리하는 방식이 더 자연스러울 수 있기 때문이다. + +그러나 이번 구현의 핵심 처리 흐름은 다음과 같다: + +``` +product_metrics_hourly에서 기간 범위 읽기 +→ product_id 별 집계 (SUM) +→ 점수 계산 후 TOP 100 정렬 +→ mv_product_rank_weekly / mv_product_rank_monthly 저장 +``` + +Spring Batch Chunk의 전제는 `1건 읽기 → 1건 가공 → N건 쓰기`인데, +집계(GROUP BY)가 포함되면 여러 시간 버킷 row → 1개 product_id 집계값이 되어 Chunk 구조와 어색해진다. + +이 문제는 **SQL에서 GROUP BY 집계까지 처리**하면 해결된다. + +```sql +SELECT product_id, + SUM(view_count), SUM(like_count), SUM(order_count), SUM(order_amount) +FROM product_metrics_hourly +WHERE bucket_hour BETWEEN :start AND :end +GROUP BY product_id +``` + +Reader가 이미 집계된 결과(product_id 1건 = 1row)를 읽으므로 Chunk 구조에 자연스럽게 맞아떨어진다. +결과적으로 Spring Batch가 제공하는 페이징, 재시작각각을 , 건수 추적 혜택을 그대로 받을 수 있다. + +**결정: SQL 집계 + Spring Batch Chunk 방식** + +--- + +## 선택 2. ItemReader 방식 ✅ 결정 + +### 선택지 비교 + +**A. JdbcCursorItemReader** — 커서 기반 스트리밍 + +DB 커넥션을 유지하면서 결과를 한 줄씩 읽어온다. + +- **적합한 사례**: 수백만 건 로그 정제, 순서 보장이 중요한 이벤트 처리, 단발성 배치 +- **단점**: Step 실행 동안 DB 커넥션 1개를 점유 — 배치 시간이 길면 커넥션 풀 압박 + +**B. JdbcPagingItemReader** — 페이지 단위 조회 + +chunk 처리마다 새 쿼리로 페이지를 가져오고 커넥션을 반납한다. + +- **적합한 사례**: 실패 시 마지막 페이지부터 재시작이 필요한 정산 배치, 배치 실행 시간이 긴 경우, 병렬 Step으로 커넥션 경합이 생기는 환경 +- **단점**: GROUP BY + ORDER BY 복잡한 쿼리에서 페이징 정렬 키 설정이 까다로움 + +### 고민한 내용 + +선택 1에서 SQL GROUP BY 집계 결과를 Reader가 읽는 방식으로 확정했으므로 JDBC 기반 Reader가 자연스럽다. + +이번 구현의 특성을 따져보면: +- 읽는 데이터는 product_id별 집계 결과로 볼륨이 크지 않음 (최대 상품 수만큼) +- 1일 1회 실행, 실행 시간이 짧아 재시작 필요성이 낮음 +- GROUP BY 쿼리에 페이징 키를 추가하면 쿼리가 복잡해짐 + +**결정: JdbcCursorItemReader** + +재시작 중요도가 낮고 GROUP BY 쿼리 복잡도를 피할 수 있어 `JdbcCursorItemReader`가 더 적합하다. + +--- + +## 선택 3. 주간/월간 기간 기준 ✅ 결정 + +### 선택지 비교 + +| 기준 | 설명 | 예시 (기준일: 2024-01-10) | +|------|------|--------------------------| +| 슬라이딩 윈도우 | 기준일 기준 직전 7일/30일 | 주간: 01-04 ~ 01-10 / 월간: 12-11 ~ 01-10 | +| ISO 주차 / 역월 고정 | 해당 주 월~일 / 해당 월 1일~말일 | 주간: 01-08 ~ 01-14 / 월간: 01-01 ~ 01-31 | + +### 트레이드오프 + +| | 슬라이딩 윈도우 | ISO 주차/역월 | +|--|--------------|--------------| +| 랭킹 변경 시점 | 매일 (배치 실행마다) | 주/월이 바뀔 때 | +| 데이터 신선도 | 높음 — 항상 최근 N일 기준 | 낮음 — 주/월 전환 시점에만 갱신 | +| 과거 랭킹 조회 | 어렵다 (날마다 결과가 다름) | 쉽다 (2024년 1월 = 고정값) | +| MV 테이블 설계 | 날짜 키 필요, 데이터 누적 관리 필요 | 주차/월 키로 단순 설계 가능 | +| API 구현 | date-base_date 매핑 및 fallback 필요 | 날짜로 주차/월 계산 후 바로 조회 | +| 배치 주기 | 매일 실행 필요 | 주 1회 / 월 1회로 충분 | + +### 고민한 내용 + +"주간 베스트"라는 이름이 반드시 ISO 주차를 의미하지는 않는다. 슬라이딩 윈도우로도 "주간 베스트"를 구현할 수 있으며, 오히려 사용자가 어느 요일에 접속해도 항상 최근 7일 기준의 생생한 랭킹을 볼 수 있다는 장점이 있다. + +커머스 서비스에서 실시간성 높은 랭킹을 제공하려면 슬라이딩 윈도우가 더 적합하다. ISO 방식은 과거 스냅샷 조회나 리포트 용도에 강점이 있지만, 이번 과제의 목적은 사용자에게 현시점 기준의 주간/월간 인기 상품을 보여주는 것이다. + +MV 테이블에 날짜 키가 필요하고 갱신 전략이 다소 복잡해지는 트레이드오프가 있지만 구현 가능한 수준이다. + +**결정: 슬라이딩 윈도우 방식** + +`jobParameter`로 `targetDate`를 받아 배치 실행 시점에 기간을 계산한다. + +### 부가 결정: 슬라이딩 윈도우 범위 ✅ + +| | 선택 A | 선택 B | +|--|--------|--------| +| 주간 | 오늘 포함 직전 7일 | **어제 기준 직전 7일** | +| 월간 | 오늘 포함 직전 30일 | **어제 기준 직전 30일** | + +배치는 새벽에 실행되므로 오늘 데이터는 당일 0시~배치 실행 시각까지만 쌓인 불완전한 상태다. +어제 기준으로 잡으면 전날까지 완전히 쌓인 데이터만 집계하므로 항상 안정적인 랭킹을 제공할 수 있다. + +**결정: 선택 B — 어제(targetDate - 1일) 기준 직전 7일/30일** + +``` +주간: targetDate - 7일 ~ targetDate - 1일 +월간: targetDate - 30일 ~ targetDate - 1일 +``` + +--- + +## 선택 4. MV 테이블 갱신 전략 ✅ 결정 + +### 선택지 비교 + +슬라이딩 윈도우 방식에서 TRUNCATE는 다른 날짜 데이터까지 삭제하므로 제외. +DELETE + INSERT vs UPSERT 중 선택. + +**DELETE + INSERT (트랜잭션)** + +```sql +BEGIN; +DELETE FROM mv_product_rank_weekly WHERE base_date = :baseDate; +INSERT INTO mv_product_rank_weekly VALUES (...); +COMMIT; +``` + +| 장점 | 단점 | +|------|------| +| 구현 단순, 직관적 | 트랜잭션 크기에 따라 락 경합 가능 | +| 전체 교체로 데이터 정합성 항상 보장 | 트랜잭션 범위가 길수록 조회 대기 발생 가능 | +| 탈락 상품 자동 제거 | - | +| 배치 실패 시 롤백으로 이전 데이터 유지 | - | + +**UPSERT (INSERT ON DUPLICATE KEY UPDATE)** + +```sql +INSERT INTO mv_product_rank_weekly (product_id, base_date, rank, score) +VALUES (:productId, :baseDate, :rank, :score) +ON DUPLICATE KEY UPDATE rank = VALUES(rank), score = VALUES(score); +``` + +| 장점 | 단점 | +|------|------| +| 트랜잭션 범위 작아 락 경합 낮음 | 배치 재실행 시 같은 base_date의 이전 레코드가 잔류할 수 있음 | +| 원자적 연산 | 재실행 안전성을 위해 결국 DELETE를 앞에 붙여야 함 | +| - | DELETE를 붙이면 DELETE + INSERT와 구조가 동일해져 UPSERT의 장점이 사라짐 | + +### 고민한 내용 + +UPSERT는 같은 `base_date` 내에서 배치 재실행 시 이전 레코드가 잔류하는 문제가 있다. 예를 들어 1차 실행 도중 실패해 상품 A가 부분 적재된 상태에서, 2차 재실행 시 상품 A가 TOP 100 밖으로 밀리면 1차 레코드가 그대로 남는다. 이를 해결하려면 재실행 전 해당 `base_date` 데이터를 먼저 DELETE해야 하는데, 결국 DELETE + INSERT와 동일한 구조가 된다. + +이번 구현은 매일 직전 7일/30일치를 새로 계산해 TOP 100을 완전히 교체하는 방식이므로, **전체 교체가 자연스럽고 구현이 단순한 DELETE + INSERT가 더 적합**하다. + +**결정: DELETE + INSERT (트랜잭션으로 묶어 원자적 처리)** + +--- + +## 선택 5. API 파라미터 확장 방식 ✅ 결정 + +### 선택지 비교 + +**A. period 파라미터 추가** +``` +GET /api/v1/rankings?date=20240110&period=daily (기존 Redis) +GET /api/v1/rankings?date=20240110&period=weekly (MV 테이블) +GET /api/v1/rankings?date=20240110&period=monthly (MV 테이블) +``` + +| 장점 | 단점 | +|------|------| +| 기존 엔드포인트 변경 범위 최소 | period에 따라 데이터 소스가 달라 컨트롤러/파사드에 분기 발생 | +| 하나의 URI로 통일감 | 내부 복잡도가 높아져 유지보수 어려움 | + +**B. 별도 엔드포인트** +``` +GET /api/v1/rankings/daily?date=20240110 +GET /api/v1/rankings/weekly?date=20240110 +GET /api/v1/rankings/monthly?date=20240110 +``` + +| 장점 | 단점 | +|------|------| +| 각 엔드포인트가 단일 책임 | 컨트롤러/파사드 코드가 늘어남 | +| 데이터 소스가 달라도 독립적으로 구현 가능 | - | +| 각 기간별 독립적인 확장 가능 | - | +| RESTful 관점에서 리소스 성격이 명확 | - | + +### 고민한 내용 + +`daily`는 Redis, `weekly`/`monthly`는 MV 테이블로 데이터 소스가 근본적으로 다르다. A 방식으로 구현하면 하나의 메서드 안에서 `period` 값에 따라 분기가 생기고, 나중에 각 기간별로 다른 요구사항이 생길 때 점점 복잡해진다. + +단일 책임 원칙과 RESTful 설계 관점에서 **조회하는 리소스의 성격이 다르면 URI로 구분하는 것이 명확**하다. + +**결정: B — 별도 엔드포인트로 분리** + +--- + +## 선택 6. 스케줄링 방식 ✅ 결정 + +### 핵심 원칙: 외부 오케스트레이션 도구 사용 + +Job 간 의존 관계나 스케줄링을 **코드 내부(Job Chaining 등)에서 제어하지 않는다.** +코드 내부에서 흐름을 묶으면 장애 발생 시 영향 범위가 커지고, 중간 중단이 어려우며 모니터링도 불리하다. +각 배치는 독립적인 실행 단위로 만들고, 제어는 외부 스케줄러에 맡기는 것이 현업의 추세다. + +### 도구별 비교 + +| 도구 | 적합한 상황 | 특징 | +|------|-----------|------| +| `@Scheduled` | 단순 단일 인스턴스, 프로토타입 | 분산 환경 중복 실행 위험, 모니터링 없음 | +| Quartz | 분산 환경 + 코드 내 스케줄링 | DB 기반 락, 운영 복잡도 있음 | +| Jenkins / Cron | 일반적인 현업 환경 | 운영 비용 낮음, 심플, 가장 많이 사용 | +| K8s CronJob | 쿠버네티스 기반 인프라 | 인프라 레벨 스케줄링, 배치 앱 단순하게 유지 | +| Airflow | 고도화된 데이터 파이프라인 환경 | DAG 기반 의존 관계 관리, 운영 난이도 높음 | +| Argo Workflows | 이미 Argo를 CI/CD로 사용 중인 대규모 환경 | 쿠버네티스 네이티브, 운영 비용 높음 | + +### 규모별 선택 기준 + +- **기본 수준** : Jenkins 또는 K8s CronJob + - 운영 비용이 낮고 심플하여 별도의 빅데이터 인프라가 없는 환경에 적합 +- **고도화된 대규모 환경** : Airflow 또는 Argo Workflows + - 이미 회사에서 해당 도구를 CI/CD나 데이터 마트 관리 목적으로 쓰고 있을 때 함께 활용 + +**결정: 현재 프로젝트 규모에서는 K8s CronJob (외부 스케줄링) 수준으로 설계** + +배치 앱은 `--job.name` 파라미터를 받아 독립적으로 실행되는 단위로 유지하고, 스케줄링은 외부에 위임하는 구조로 구현한다. + +--- + +# 실시간 집계 vs 배치 + MV 조회 + +## 실시간 집계 방식 + +```sql +-- 랭킹 조회 요청마다 실행 +SELECT product_id, + LN(1 + SUM(view_count)) * ? + + LN(1 + SUM(like_count)) * ? + + LN(1 + SUM(order_amount)) * ? AS score +FROM product_metrics_hourly +WHERE bucket_hour BETWEEN :start AND :end +GROUP BY product_id +ORDER BY score DESC +LIMIT 100; +``` + +- 매 요청마다 전체 기간 데이터를 Full Scan + GROUP BY + Sort +- 주간 = 7일 × 24시간 = 168개 row/상품, 월간 = 720개 row/상품 +- 상품 수 × row 수만큼 매번 집계 → **요청이 많아질수록 DB 부하가 선형으로 증가** + +## 배치 + MV 조회 방식 + +```sql +-- 배치가 새벽에 1회 실행해 결과를 MV 테이블에 저장 +-- 랭킹 조회 요청은 단순 PK 조회 +SELECT * FROM mv_product_rank_weekly WHERE base_date = ?; +``` + +- 집계는 배치가 새벽에 1회만 수행 +- 조회 시점에는 이미 계산된 결과를 단순 SELECT → **요청 수와 무관하게 응답 시간 일정** + +## 왜 배치 + MV가 더 나은가 + +| 항목 | 실시간 집계 | 배치 + MV | +|------|-----------|----------| +| 조회 쿼리 비용 | Full Scan + GROUP BY + Sort | PK 단순 조회 | +| 동시 요청 증가 시 | DB 부하 선형 증가 | 부하 없음 (이미 계산됨) | +| 응답 시간 | 데이터 양에 비례 | 항상 일정 | +| 집계 실행 횟수 | 요청마다 | 하루 1회 | +| 데이터 신선도 | 실시간 | 배치 실행 주기만큼 지연 | + +## 트레이드오프 + +배치 + MV 방식의 단점은 **데이터 신선도**다. +배치가 새벽 2시에 실행된다면 오전 10시에 조회해도 어제 기준 랭킹을 보게 된다. + +하지만 주간/월간 랭킹은 **하루 단위로 변해도 사용자가 체감하는 차이가 크지 않다.** +실시간 반영이 중요한 일간 랭킹은 Redis ZSET으로 처리하고, +주간/월간처럼 집계 비용이 크고 신선도 요구가 낮은 경우에는 배치 + MV가 적합하다. + +``` +일간 랭킹 → Redis ZSET (실시간, 메모리 기반) +주간 랭킹 → MV 테이블 (배치 집계, 하루 1회 갱신) +월간 랭킹 → MV 테이블 (배치 집계, 하루 1회 갱신) +``` + +--- + +# Spring Batch Listener 구현 + +Spring Batch의 Job/Step/Chunk 실행 흐름에 부가 로직을 끼워넣기 위해 3개의 Listener를 구현했다. +실행 이력(성공/실패 상태, 시작/종료 시각)은 Spring Batch가 `BATCH_*` 테이블에 자동으로 기록하므로, +Listener는 그 외 **모니터링, 로깅, 알림** 등 부가 작업에 집중한다. + +## JobListener + +- `@BeforeJob`: Job 시작 시 Job 이름 로깅 + 시작 시각을 `ExecutionContext`에 저장 +- `@AfterJob`: Job 종료 시 시작/종료 시각과 총 소요 시간(시간/분/초)을 로깅 + +```java +@BeforeJob +void beforeJob(JobExecution jobExecution) { + log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); +} + +@AfterJob +void afterJob(JobExecution jobExecution) { + // ExecutionContext에서 시작 시각 복원 후 소요 시간 계산 및 로깅 +} +``` + +## StepMonitorListener + +- `@BeforeStep`: Step 시작 시 Step 이름 로깅 +- `@AfterStep`: Step 종료 시 실패 예외가 있으면 jobName + 예외 메시지 로깅 + - Slack 등 외부 알림 채널 연동 포인트 (현재는 주석 처리) + - 실패 시 `ExitStatus.FAILED` 반환으로 Job 상태에 반영 + +```java +@Override +public ExitStatus afterStep(StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; +} +``` + +## ChunkListener + +- `@AfterChunk`: 청크 처리 완료 후 readCount, writeCount 로깅 + +```java +@AfterChunk +void afterChunk(ChunkContext chunkContext) { + log.info("청크 종료: readCount: {}, writeCount: {}", + chunkContext.getStepContext().getStepExecution().getReadCount(), + chunkContext.getStepContext().getStepExecution().getWriteCount() + ); +} +``` + +--- + +# 코드 리뷰 반영 + +## 1. rank 할당의 Multi-Chunk 취약점 수정 + +`WeeklyRankingItemWriter` / `MonthlyRankingItemWriter` 에서 rank 를 `int rank = 1` 부터 순서대로 할당하는 방식은 +`write()` 가 여러 번 호출되는 다중 Chunk 환경에서 매 호출마다 rank 가 1 부터 재시작되는 버그가 발생한다. + +현재는 `LIMIT 100` + `chunk(100)` 으로 단일 Chunk 가 보장되지만, 둘 중 하나만 변경되면 조용히 깨진다. + +**수정 내용** + +- `WeeklyRankingJobConfig`, `MonthlyRankingJobConfig` 에 `public static final int TOP_N = 100` 상수 추가 +- SQL `LIMIT 100` → `LIMIT %d".formatted(TOP_N)` 으로 상수 참조 +- `chunk(100, ...)` → `chunk(TOP_N, ...)` 으로 통일 +- Writer 에 단일 Chunk 전제 경고 주석 추가 + +--- + +## 2. Controller → Facade 내부 메서드 노출 수정 + +Controller 가 `resolveBaseDate()`, `getWeeklyTotal()` 등 Facade 내부 메서드를 직접 호출하고 있었다. +동일한 `date` 에 대해 `resolveBaseDate` 가 최대 3회 중복 실행되는 문제도 있었다. + +**수정 내용** + +`RankingPageResult(effectiveDate, total, items)` record 를 신규 도입하여 +Facade 가 items + total + effectiveDate 를 하나로 묶어 반환하도록 변경했다. +Controller 는 Facade 를 단일 호출하고 결과를 그대로 사용한다. + +일간(`RankingFacade`) / 주간(`WeeklyRankingFacade`) / 월간(`MonthlyRankingFacade`) 모두 동일하게 적용하여 +Facade 간 일관성을 유지했다. + +- `getDailyTotal`, `today()`, `getWeeklyTotal`, `getMonthlyTotal`, `resolveBaseDate` (public) 제거 +- 3개 Controller 단순화, 3개 Facade 테스트 반환 타입 수정 + +--- + +## 3. DELETE + INSERT 트랜잭션 미명시 수정 + +`JdbcMvProductRankRepository` 의 `replaceWeeklyRanking` / `replaceMonthlyRanking` 은 +DELETE + INSERT 를 원자적으로 처리해야 하지만 `@Transactional` 이 없었다. + +현재는 Spring Batch Chunk 트랜잭션이 암묵적으로 보장하고 있으나, +Repository 가 자신의 원자성을 호출자에게 위임하면 Batch 외부 호출 시 데이터 소실 위험이 있다. + +**수정 내용** + +- `replaceWeeklyRanking`, `replaceMonthlyRanking` 에 `@Transactional` 추가 +- Spring 기본 전파 속성(`REQUIRED`) 으로 Batch 트랜잭션 안에서는 합류, 외부 호출 시 자체 트랜잭션 생성 +- 클래스 Javadoc 에 REQUIRED 전파 동작 명시 + +--- + +## 4. order_amount 미사용 버그 수정 + +배치 SQL 이 `SUM(order_count) * weight` 를 사용하고 있었다. +일간 랭킹(`RankingScoreCalculator`) 은 week9 에서 다음 근거로 `log1p(order_amount)` 를 채택했다. + +- `order_count` (건수) — 매출 기여 무시 (기각) +- `order_amount` 원본 — 스케일 폭주로 view/like 압도 (기각) +- `log1p(order_amount)` — 스케일 압축 + 매출 반영 + 가중치 의미 유지 (채택) + +주간/월간 배치가 이 결정을 반영하지 않아 일간과 점수 계산 공식이 불일치했다. + +**수정 내용** + +배치 AGGREGATION_SQL 을 일간과 동일한 공식으로 수정했다. + +```sql +-- 수정 전 +SUM(view_count) * ? + SUM(like_count) * ? + SUM(order_count) * ? + +-- 수정 후 +LN(1 + SUM(view_count)) * ? ++ LN(1 + SUM(like_count)) * ? ++ LN(1 + SUM(order_amount)) * ? +``` + +배치 E2E 테스트 데이터도 `order_count` 기반에서 `order_amount` 기반으로 교체했다. + +--- + +## 5. 테스트 given/when/then 주석 누락 수정 + +`WeeklyRankingJobE2ETest`, `MonthlyRankingJobE2ETest` 의 `populatesMvTableWithRanking`, +`replacesExistingMvOnRerun` 케이스에 given/when/then 주석이 누락되어 있었다. + +**수정 내용** + +누락된 4개 테스트 케이스에 given/when/then 주석 추가. +`replacesExistingMvOnRerun` 은 1차 실행(given) → 2차 실행(when) → 검증(then) 흐름이 +명확히 드러나도록 구조도 함께 정리했다. + +--- + +## 6. Interfaces Layer의 Domain enum 직접 참조 제거 + +`RankingV1Dto`(Interfaces Layer) 의 `RankingItemResponse.status` 필드가 +`ProductStatus`(Domain enum) 를 직접 import하고 있었다. + +Interfaces Layer 는 Application Layer 를 통해서만 Domain 타입을 간접 참조해야 하므로 +Interfaces → Domain 직접 의존은 레이어 경계 위반이다. + +**수정 내용** + +- `status` 필드 타입을 `ProductStatus` → `String` 으로 변경 +- `p.status()` → `p.status().name()` 으로 변환 (API 응답 형식 동일) +- `import com.loopers.domain.product.ProductStatus` 제거 + +--- + +## 7. batch.ranking 도메인 타입 패키지 이동 (domain.ranking) + +`MvProductRankRepository`, `MvProductRankRow`, `ProductMetricsAggregate` 가 +`com.loopers.batch.ranking` 패키지에 위치하고 있었다. + +commerce-api 는 Repository 인터페이스와 도메인 VO 를 `com.loopers.domain.ranking` 에 두는 반면, +commerce-batch 의 도메인 타입은 `batch.ranking` 에 혼재하여 패키지 네이밍 일관성이 없었다. + +**수정 내용** + +- `com.loopers.batch.ranking` → `com.loopers.domain.ranking` 으로 패키지 이동 +- 영향받는 5개 파일 import 갱신 + - `WeeklyRankingJobConfig`, `MonthlyRankingJobConfig` + - `WeeklyRankingItemWriter`, `MonthlyRankingItemWriter` + - `JdbcMvProductRankRepository` +- 기존 `batch/ranking` 디렉토리 삭제 + +이로써 commerce-batch 의 의존 방향이 commerce-api 와 동일한 구조를 갖는다: +``` +batch.job.** (Job/Step/Writer) → domain.ranking (인터페이스/VO) ← infrastructure.ranking (JDBC 구현체) +``` + +--- + +## 8. 배치 스케줄 트리거 구성 (Jenkins + Cron) + +### 배치와 API를 분리하는 이유 + +| 관심사 | 이유 | +|---|---| +| 리소스 격리 | 배치 실행 중 CPU/메모리 집중 사용이 API 응답 시간에 영향을 주지 않도록 분리 | +| 배포 독립성 | API는 트래픽에 따라 수평 확장, 배치는 스케줄 시점에만 단일 실행 | +| 장애 격리 | 배치가 OOM으로 종료되어도 API 서비스는 영향 없음 | + +### 실행 방식 + +실무에서 대규모 인프라 없이 가장 현실적인 방식은 **Jenkins + Shell Script** 조합이다. + +- Jenkins(또는 Linux crontab)가 지정 시각에 스크립트를 호출 +- 스크립트가 `targetDate` 를 계산하여 배치 JAR 를 실행 +- 배치 앱은 Job 완료 후 프로세스 종료 (exit code 반환) +- Jenkins 가 exit code 로 성공/실패 판단 후 슬랙 알림 등 후처리 + +### 실행 흐름 + +``` +Jenkins Cron Trigger (매일 새벽 2시) + │ + ▼ +./scripts/run-weekly-ranking.sh + │ + ├─ targetDate 결정 (인자 없으면 오늘 날짜) + ▼ +java -jar commerce-batch.jar \ + --spring.profiles.active=prd \ + --job.name=weeklyRankingJob \ + targetDate=2026-04-16 + │ + ├─ @ConditionalOnProperty → WeeklyRankingJob 빈만 로드 + ├─ Job 실행 완료 + └─ 프로세스 종료 (exit code 반환) + │ + ▼ +Jenkins → 성공/실패 판단 → 슬랙 알림 등 후처리 +``` + +### 스크립트 구성 (`scripts/`) + +| 파일 | 실행 Job | 기본 Cron | +|---|---|---| +| `run-weekly-ranking.sh` | weeklyRankingJob | 매일 새벽 2:00 | +| `run-monthly-ranking.sh` | monthlyRankingJob | 매일 새벽 2:30 | + +두 스크립트 모두 아래 특성을 갖는다: +- `targetDate` 인자 생략 시 오늘 날짜 자동 설정 +- `JAR_PATH`, `SPRING_PROFILE` 환경변수로 경로·프로필 재정의 가능 +- `set -euo pipefail` 으로 오류 발생 시 즉시 종료 및 exit code 전달 + +### application.yml 설계 + +`spring.batch.job.name: ${job.name:NONE}` 설정이 외부 트리거 방식을 지원한다. + +- 실행 시 `--job.name=weeklyRankingJob` 을 넘기면 해당 Job 빈만 로드 +- `@ConditionalOnProperty` 로 Job 별 컨텍스트 격리 +- `web-application-type: none` 으로 Job 완료 후 자동 종료 + +### Jenkins Pipeline 예시 + +```groovy +triggers { + cron('0 2 * * *') // 매일 새벽 2시 +} + +stage('Weekly Ranking Batch') { + steps { + sh './scripts/run-weekly-ranking.sh' + } +} + +stage('Monthly Ranking Batch') { + steps { + sh './scripts/run-monthly-ranking.sh' + } +} +``` + +--- + +## 코드 리뷰 반영 (2차) + +### 1. `rank` 컬럼 JPA 인용 방식 수정 + +`MvProductRankWeekly` / `MvProductRankMonthly` 에서 `rank` 컬럼을 JPA 표준 인용자(`"\"rank\""`)로 선언하고 있었다. +Hibernate 6.x + MySQL Dialect 환경에서 이 방식이 백틱으로 자동 변환된다는 보장이 없고, +배치 JDBC 쪽은 이미 백틱으로 직접 발행하고 있어 인용 전략이 불일치했다. + +**수정 내용** + +- `@Column(name = "\"rank\"")` → `@Column(name = "`rank`")` 로 변경 +- 배치 JDBC 와 API JPA 의 인용 전략을 백틱으로 통일 + +```java +// 수정 전 +@Column(name = "\"rank\"", nullable = false) +private int rank; + +// 수정 후 +@Column(name = "`rank`", nullable = false) +private int rank; +``` + +--- + +### 2. `RankingAssembler` 도입 — Facade 파이프라인 중복 제거 + +`RankingFacade` / `WeeklyRankingFacade` / `MonthlyRankingFacade` 세 곳에 동일한 조회 파이프라인이 반복되어 있었다. + +``` +entries → productIds 추출 → findVisibleByIds → RankingItemInfo.of → RankingPageResult +``` + +차이는 저장소 타입과 baseDate 기본값(오늘/어제)뿐이었고, 나머지 로직은 100% 동일했다. +가시성 필터 정책 변경 시 세 곳을 동시에 수정해야 하는 Shotgun Surgery 안티패턴이었다. + +**수정 내용** + +- `RankingAssembler` 컴포넌트 신규 도입 + - 공통 파이프라인 (`entries → 가시성 필터 → RankingPageResult 조립`) 을 단일 위치로 통합 + - `KST` 상수도 `RankingAssembler.KST` 로 이동하여 세 Facade 가 공통 참조 +- 세 Facade 에서 `ProductFacade` 직접 의존 제거 → `RankingAssembler` 주입으로 교체 +- `Collections.unmodifiableList` → `List.toList()` 로 교체 (불변 복사본 보장) +- `RankingAssemblerTest` 신규 작성 — 조립 로직 전담 (happyPath, visibilityFilter, emptyEntries) +- 세 Facade 테스트 슬림화 — date 결정 로직 + assembler 위임 검증만 담당 + +```java +// 수정 후 Facade — 저장소 호출 + assembler 위임만 담당 +public RankingPageResult getWeeklyRanking(LocalDate date, int pageOneBased, int size) { + LocalDate baseDate = date != null ? date : LocalDate.now(clock.withZone(RankingAssembler.KST)).minusDays(1); + long total = weeklyRankingRepository.getTotal(baseDate); + List entries = weeklyRankingRepository.getTopN(baseDate, pageOneBased, size); + return rankingAssembler.assemble(baseDate, total, entries); +} +``` + +--- + +### 3. `RankingPageQuery` 도입 — Controller 파싱 로직 중복 제거 + +`RankingV1Controller` / `WeeklyRankingV1Controller` / `MonthlyRankingV1Controller` 세 곳에 동일한 내용이 중복되어 있었다. + +- 상수: `YYYYMMDD`, `DEFAULT_PAGE`, `DEFAULT_SIZE`, `MAX_SIZE` +- 메서드: `parseDate`, `safePage`/`safeSize` 계산, BAD_REQUEST 메시지 + +**수정 내용** + +- `RankingPageQuery` record 신규 도입 (package-private) + - 정적 팩토리 `of(dateStr, page, size)` — 파싱·보정 로직을 단일 위치로 + - `formattedDate(LocalDate)` — yyyyMMdd 변환 헬퍼 +- 세 Controller 에서 파라미터 처리 4줄 → `RankingPageQuery.of(...)` 한 줄로 교체 + +```java +// 수정 전 — 세 Controller 에 동일 코드 반복 +LocalDate date = parseDate(dateStr); +int safePage = Math.max(page, DEFAULT_PAGE); +int safeSize = size <= 0 ? DEFAULT_SIZE : Math.min(size, MAX_SIZE); + +// 수정 후 +RankingPageQuery query = RankingPageQuery.of(dateStr, page, size); +``` + +--- + +### 4. `ProductStatus` 원복 + +`RankingV1Dto.RankingItemResponse.status` 필드가 `String` 으로 변경되어 있었으나, +커밋 이력 확인 결과 최초 구현(`c98824a`)에서는 `ProductStatus` 였고 +커밋 없이 워킹트리에서만 변경된 상태였다. + +`ProductStatus` → `String` 변환 시 와이어 포맷은 동일하지만, +타입 안전성이 낮아지고 OpenAPI 스키마가 enum → string 으로 변경되어 클라이언트 계약이 훼손된다. + +**수정 내용** + +- `String status` → `ProductStatus status` 원복 +- `p.status().name()` → `p.status()` 원복 (Jackson 이 enum name 을 기본 직렬화) + diff --git a/docs/week10/wil.md b/docs/week10/wil.md new file mode 100644 index 0000000000..9eb54c4db7 --- /dev/null +++ b/docs/week10/wil.md @@ -0,0 +1,40 @@ +# WIL - 10주차 (Spring Batch와 Materialized View를 통한 대규모 집계 및 주간·월간 랭킹 구현) + +## 이번 주에 새로 배운 것 + +- Spring Batch의 Job / Step / Chunk / Tasklet 구조를 직접 설계하면서, 단순히 "배치 = 스케줄러"가 아니라 **재시작 지원, Skip/Retry, 처리 건수 추적** 등 프레임워크가 제공하는 내결함성의 가치를 체감했다. +- `JdbcCursorItemReader` vs `JdbcPagingItemReader`의 트레이드오프를 경험했다. 커서 기반은 커넥션을 Step 내내 점유하는 대신 GROUP BY 쿼리를 자연스럽게 쓸 수 있고, 페이징 기반은 커넥션을 매 chunk마다 반납해 풀 압박이 낮은 대신 정렬 키 설정이 까다롭다는 차이를 실감했다. +- MySQL에 Materialized View 기능이 없어 **별도 테이블 + 배치 적재** 조합으로 동일한 효과를 내는 패턴을 직접 구현했다. MV 테이블이 "복잡한 집계 쿼리를 미리 계산해 두는 조회 전용 구조"라는 개념이 코드 레벨에서 손에 잡혔다. +- 슬라이딩 윈도우와 ISO 주차/역월 방식의 차이를 비교하며 **데이터 신선도 vs 과거 조회 편의성**이라는 축으로 기간 기준을 선택하는 사고 방식을 익혔다. +- 배치 스케줄링을 코드 내부(`@Scheduled`, Job Chaining)에서 제어하지 않고 **외부 오케스트레이터(Jenkins / K8s CronJob)에 위임**해야 하는 이유 — 장애 격리, 모니터링 가시성, 중간 중단 용이성 — 를 설계 관점에서 정리했다. + +## 이런 고민이 있었어요 + +- **Chunk vs Tasklet 내 직접 구현**: Chunk-Oriented의 전제는 `1건 읽기 → 1건 가공 → N건 쓰기`인데, GROUP BY 집계가 들어가면 여러 시간 버킷 row가 1개 product_id 집계값이 되어 구조가 어색해진다. 해결책은 SQL에서 GROUP BY까지 처리하는 것이었다. Reader가 이미 집계된 결과를 1row씩 읽으면 Chunk 구조에 자연스럽게 맞아떨어지고, 프레임워크의 재시작·건수 추적 혜택도 그대로 받을 수 있었다. +- **MV 갱신 전략 — UPSERT vs DELETE + INSERT**: UPSERT는 재실행 시 이전 base_date 레코드가 잔류해 TOP 100 밖으로 밀린 상품이 남는 문제가 있다. 이를 막으려면 결국 앞에 DELETE를 붙여야 해서 구조가 DELETE + INSERT와 동일해진다. 매일 전체 교체가 자연스러운 슬라이딩 윈도우 방식에서는 **DELETE + INSERT + 트랜잭션**이 더 직관적이고 안전했다. +- **API 파라미터 확장 — period 파라미터 vs 별도 엔드포인트**: `daily`는 Redis, `weekly`/`monthly`는 MV 테이블로 데이터 소스가 근본적으로 다르다. 하나의 메서드 안에 `period` 분기를 쑤셔 넣으면 나중에 요구사항이 달라질 때마다 복잡도가 쌓인다. 단일 책임 원칙과 RESTful 관점에서 **조회하는 리소스의 성격이 다르면 URI로 구분하는 것**이 옳다고 판단해 별도 엔드포인트로 분리했다. +- **어제 기준 슬라이딩 윈도우**: 배치는 새벽에 실행되므로 오늘 데이터는 배치 실행 시각까지만 쌓인 불완전한 상태다. `targetDate - 1일` 기준으로 잡으면 전날까지 완전히 쌓인 데이터만 집계해 항상 안정적인 랭킹을 보장할 수 있었다. + +## 코드 리뷰를 통해 배운 것 + +이번 주는 구현 이후 리뷰를 통해 설계 결함을 다수 수정했다. 특히 기억에 남는 것들: + +- **Multi-Chunk 취약점**: Writer에서 `int rank = 1`로 매 호출마다 재시작하는 방식은 현재 `LIMIT 100 + chunk(100)` 단일 Chunk 보장 덕분에 숨어있던 버그였다. 두 값 중 하나만 바뀌면 조용히 깨진다. `TOP_N = 100` 상수를 추출해 SQL의 LIMIT와 chunk 크기를 한 곳에서 통제하도록 수정하면서, **암묵적 전제를 명시적 계약으로 바꾸는** 리팩토링의 의미를 실감했다. +- **Facade 내부 메서드 노출**: Controller가 `resolveBaseDate()`, `getWeeklyTotal()` 같은 Facade 내부 메서드를 직접 호출하며 동일한 `date`에 대해 `resolveBaseDate`가 최대 3회 중복 실행되는 문제가 있었다. `RankingPageResult(effectiveDate, total, items)` record를 도입해 Facade가 모든 정보를 하나로 묶어 반환하도록 바꿨다. Controller 코드가 단순해지고 Facade 3개(일간/주간/월간) 사이의 인터페이스도 일관성을 갖게 됐다. +- **@Transactional 누락**: `replaceWeeklyRanking` / `replaceMonthlyRanking`은 DELETE + INSERT를 원자적으로 묶어야 하는데 `@Transactional`이 없었다. Spring Batch Chunk 트랜잭션이 암묵적으로 보장해주고 있었지만, Repository가 자신의 원자성을 호출자에게 위임하면 Batch 외부에서 호출 시 데이터 소실 위험이 생긴다. **"지금 동작한다"와 "의존관계가 깨져도 동작한다"는 다르다**는 점을 다시 한번 확인했다. +- **레이어 경계 위반**: `RankingV1Dto`(Interfaces Layer)의 `status` 필드가 `ProductStatus`(Domain enum)를 직접 import하고 있었다. Interfaces → Domain 직접 의존은 레이어 경계 위반이므로 `String`으로 변환해 해결했다. 의존 방향 규칙은 지키고 있다고 생각했는데, DTO의 필드 타입 하나까지 신경 써야 한다는 점이 인상적이었다. +- **점수 계산 공식 불일치**: 일간 랭킹은 9주차에 `log1p(order_amount)` 방식으로 확정했는데, 주간/월간 배치 SQL은 `SUM(order_count) * weight`를 그대로 쓰고 있었다. 일간과 주간/월간의 점수 기준이 다르면 "종합 랭킹"으로서의 의미가 없어진다. 배치 SQL도 동일하게 `LN(1 + SUM(order_amount)) * ?`로 맞췄다. + +## 앞으로 실무에 써먹을 수 있을 것 같은 포인트 + +- **SQL에서 집계 후 Chunk 구조에 맞추는 패턴**: GROUP BY를 Reader 단에서 처리하면 Processor/Writer는 단일 집계 row를 다루는 단순한 구조가 된다. 복잡한 집계 배치라도 이 원칙으로 접근하면 Spring Batch의 재시작·건수 추적 기능을 포기하지 않아도 된다. +- **Materialized View = 별도 테이블 + 배치 적재**: MySQL처럼 MV가 없는 환경에서도 복잡한 집계 조회의 응답 속도 문제를 배치로 해결할 수 있다. "매 요청마다 집계하면 너무 비싸다" → "미리 계산해 두자"라는 사고 흐름이 자연스러워졌다. +- **배치 앱은 독립 실행 단위로 유지하고, 스케줄링은 외부에 위임**: `--job.name` 파라미터로 어떤 Job을 실행할지 주입받고, `@ConditionalOnProperty`로 Job별 컨텍스트를 격리하고, 프로세스는 Job 완료 후 자동 종료(exit code 반환)하는 구조. 이 설계 패턴 하나로 Jenkins든 K8s CronJob이든 외부 오케스트레이터와 유연하게 결합할 수 있다. +- **암묵적 전제를 명시적 계약으로**: `LIMIT 100 + chunk(100)` 처럼 두 값이 함께 맞아야 동작하는 조건은 상수 하나로 묶어 한 곳에서 관리해야 한다. "지금 동작한다"에 안주하지 않고 변경에 강한 코드를 쓰는 습관. + +## 아쉬웠던 점 & 다음에 해보고 싶은 것 + +- 이번 과제에서 배치 재실행(idempotency)은 DELETE + INSERT로 보장했지만, 실패 시 부분 롤백 후 중단된 지점부터 재시작하는 **Spring Batch의 재시작(restart) 기능**은 실제로 활용해보지 못했다. `JdbcPagingItemReader`를 써서 페이지 단위 재시작이 가능한 구조를 실험해보고 싶다. +- 현재는 배치가 단일 Step으로 구성되어 있는데, 주간 배치와 월간 배치를 **병렬 Step**으로 묶어 실행 시간을 단축하는 구조도 시도해보고 싶다. +- 배치 실행 결과(처리 건수, 실행 시간, 실패 여부)를 Slack으로 알림 보내는 `JobExecutionListener` 연동까지 구현하면 실무에 바로 쓸 수 있는 수준이 될 것 같다. +- 10주간 설계 → 동시성 → 성능 → 회복력 → 이벤트 → 확장성 → 데이터 파이프라인 → 집계까지 이어지는 흐름을 한 코드베이스에서 경험한 게 가장 큰 소득이었다. 앞으로 새로운 기능을 만들 때 "이 문제를 어떤 도구로 풀어야 하나"를 고민하는 기준점이 생긴 느낌이다.