From f11e96bb6b160f91398243de524183da15f42340 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 16:44:49 +0900
Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20anchorDate=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?=
=?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=EB=84=88=EC=99=80=20=EB=A1=A4=EB=A7=81=20?=
=?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0=20=EA=B3=84=EC=82=B0=EA=B8=B0=20?=
=?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- RollingWindow(VO): anchor_date 기준 LAST_7D/LAST_30D 경계 보관
- RollingWindowResolver: yyyyMMdd 파싱 + STRICT 검증
- RankingJobParametersListener: beforeJob 에서 JobParameter.anchorDate →
ExecutionContext 로 경계 5개 주입 (재시작 시 덮어쓰기 금지)
- 단위 테스트 9종: 경계 계산, 오늘 제외, 월 경계 crossing, 무효 입력 거부
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../param/RankingJobParametersListener.java | 45 +++++++++
.../job/ranking/param/RollingWindow.java | 39 ++++++++
.../ranking/param/RollingWindowResolver.java | 41 ++++++++
.../RankingJobParametersListenerTest.java | 74 +++++++++++++++
.../param/RollingWindowResolverTest.java | 93 +++++++++++++++++++
5 files changed, 292 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
new file mode 100644
index 0000000000..4d79813a77
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
@@ -0,0 +1,45 @@
+package com.loopers.batch.job.ranking.param;
+
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+import org.springframework.batch.item.ExecutionContext;
+import org.springframework.stereotype.Component;
+
+/**
+ * anchorDate 파라미터로부터 롤링 윈도우 경계를 계산하여
+ * JobExecution 의 ExecutionContext 에 주입한다.
+ *
+ * 이후 Step 의 Reader 쿼리는 {@code @Value("#{jobExecutionContext['last7dStart']}")} 방식으로
+ * 이 값을 바인딩받아 사용한다.
+ *
+ * 재시작 시 ExecutionContext 는 유지되므로, beforeJob 은 최초 실행 시에만 기록하도록
+ * 이미 값이 있으면 덮어쓰지 않는다.
+ */
+@Component
+public class RankingJobParametersListener implements JobExecutionListener {
+
+ public static final String PARAM_ANCHOR_DATE = "anchorDate";
+
+ public static final String CTX_ANCHOR_DATE_KEY = "anchorDateKey";
+ public static final String CTX_LAST_7D_START = "last7dStart";
+ public static final String CTX_LAST_7D_END = "last7dEnd";
+ public static final String CTX_LAST_30D_START = "last30dStart";
+ public static final String CTX_LAST_30D_END = "last30dEnd";
+
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ ExecutionContext ctx = jobExecution.getExecutionContext();
+ if (ctx.containsKey(CTX_ANCHOR_DATE_KEY)) {
+ return;
+ }
+
+ String anchorDateParam = jobExecution.getJobParameters().getString(PARAM_ANCHOR_DATE);
+ RollingWindow window = RollingWindowResolver.resolve(anchorDateParam);
+
+ ctx.putString(CTX_ANCHOR_DATE_KEY, window.anchorDateKey());
+ ctx.putString(CTX_LAST_7D_START, window.last7dStart().toString());
+ ctx.putString(CTX_LAST_7D_END, window.last7dEnd().toString());
+ ctx.putString(CTX_LAST_30D_START, window.last30dStart().toString());
+ ctx.putString(CTX_LAST_30D_END, window.last30dEnd().toString());
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java
new file mode 100644
index 0000000000..b70370cbc9
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java
@@ -0,0 +1,39 @@
+package com.loopers.batch.job.ranking.param;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * anchor_date 를 기준으로 계산된 LAST_7D / LAST_30D 롤링 윈도우 경계.
+ *
+ * - 오늘은 항상 제외된다 (anchor = 어제).
+ * - 경계는 exclusive: bucket_time >= start AND bucket_time < end.
+ * - last7dEnd 와 last30dEnd 는 동일 (= anchor + 1일 00:00). 둘 다 "오늘 0시" 를 상한으로 둠.
+ */
+public record RollingWindow(
+ LocalDate anchorDate,
+ String anchorDateKey, // yyyyMMdd
+ LocalDateTime last7dStart,
+ LocalDateTime last7dEnd,
+ LocalDateTime last30dStart,
+ LocalDateTime last30dEnd
+) {
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ public static RollingWindow of(LocalDate anchorDate) {
+ LocalDateTime last7dStart = anchorDate.minusDays(6).atStartOfDay();
+ LocalDateTime last7dEnd = anchorDate.plusDays(1).atStartOfDay();
+ LocalDateTime last30dStart = anchorDate.minusDays(29).atStartOfDay();
+ LocalDateTime last30dEnd = anchorDate.plusDays(1).atStartOfDay();
+
+ return new RollingWindow(
+ anchorDate,
+ anchorDate.format(KEY_FORMAT),
+ last7dStart,
+ last7dEnd,
+ last30dStart,
+ last30dEnd
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java
new file mode 100644
index 0000000000..e8913d7a64
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java
@@ -0,0 +1,41 @@
+package com.loopers.batch.job.ranking.param;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.time.temporal.ChronoField;
+
+/**
+ * JobParameter 로 받은 anchorDate(yyyyMMdd) 를 {@link RollingWindow} 로 변환한다.
+ * 외부 플랫폼(Cron/K8s/Airflow) 이 주입한 값만 신뢰하며 {@code LocalDate.now()} 같은
+ * 트리거 시간 의존은 허용하지 않는다 (트리거 시간 != 데이터 경계).
+ */
+public final class RollingWindowResolver {
+
+ // STRICT resolver 로 "20260230" 같은 무효 날짜를 예외 처리 (SMART 는 관대하게 보정함)
+ private static final DateTimeFormatter KEY_FORMAT = new DateTimeFormatterBuilder()
+ .appendValue(ChronoField.YEAR, 4)
+ .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+ .appendValue(ChronoField.DAY_OF_MONTH, 2)
+ .toFormatter()
+ .withResolverStyle(ResolverStyle.STRICT);
+
+ private RollingWindowResolver() {
+ }
+
+ public static RollingWindow resolve(String anchorDateKey) {
+ if (anchorDateKey == null || anchorDateKey.isBlank()) {
+ throw new IllegalArgumentException("anchorDate 파라미터가 필요합니다 (yyyyMMdd)");
+ }
+ LocalDate anchorDate;
+ try {
+ anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException(
+ "anchorDate 포맷이 잘못되었습니다 (yyyyMMdd): " + anchorDateKey, e);
+ }
+ return RollingWindow.of(anchorDate);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
new file mode 100644
index 0000000000..d16a54c400
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
@@ -0,0 +1,74 @@
+package com.loopers.batch.job.ranking.param;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.MetaDataInstanceFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+class RankingJobParametersListenerTest {
+
+ private final RankingJobParametersListener listener = new RankingJobParametersListener();
+
+ @DisplayName("beforeJob 은 anchorDate 파라미터로부터 ExecutionContext 에 경계 값 5개를 주입한다.")
+ @Test
+ void populatesExecutionContextFromAnchorDate() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 1L,
+ new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
+ .toJobParameters()
+ );
+
+ listener.beforeJob(execution);
+
+ var ctx = execution.getExecutionContext();
+ assertAll(
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260414"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2026-04-08T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_END)).isEqualTo("2026-04-15T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_START)).isEqualTo("2026-03-16T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_END)).isEqualTo("2026-04-15T00:00")
+ );
+ }
+
+ @DisplayName("anchorDate 파라미터가 없으면 예외를 던진다 (Bounded 위반 차단).")
+ @Test
+ void rejectsMissingAnchorDate() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 2L,
+ new JobParametersBuilder().toJobParameters()
+ );
+
+ assertThatThrownBy(() -> listener.beforeJob(execution))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @DisplayName("재시작으로 ExecutionContext 에 이미 값이 있으면 덮어쓰지 않는다 (Bounded 유지).")
+ @Test
+ void doesNotOverwriteOnRestart() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 3L,
+ new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
+ .toJobParameters()
+ );
+ // 첫 실행이 남긴 값을 모방
+ execution.getExecutionContext().putString(
+ RankingJobParametersListener.CTX_ANCHOR_DATE_KEY, "20260101");
+ execution.getExecutionContext().putString(
+ RankingJobParametersListener.CTX_LAST_7D_START, "2025-12-26T00:00");
+
+ listener.beforeJob(execution);
+
+ var ctx = execution.getExecutionContext();
+ assertAll(
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260101"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2025-12-26T00:00")
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
new file mode 100644
index 0000000000..4c9255d512
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
@@ -0,0 +1,93 @@
+package com.loopers.batch.job.ranking.param;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+class RollingWindowResolverTest {
+
+ @DisplayName("anchorDate 로부터 LAST_7D / LAST_30D 경계를 결정적으로 계산한다.")
+ @Test
+ void resolvesBoundariesDeterministically() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
+
+ assertAll(
+ () -> assertThat(window.anchorDate()).isEqualTo(LocalDate.of(2026, 4, 14)),
+ () -> assertThat(window.anchorDateKey()).isEqualTo("20260414"),
+ () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2026, 4, 8, 0, 0)),
+ () -> assertThat(window.last7dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0)),
+ () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2026, 3, 16, 0, 0)),
+ () -> assertThat(window.last30dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0))
+ );
+ }
+
+ @DisplayName("LAST_7D 구간은 anchor 포함 7일, LAST_30D 구간은 anchor 포함 30일이다.")
+ @Test
+ void windowSpansAreCorrect() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
+
+ long last7dDays = java.time.Duration.between(window.last7dStart(), window.last7dEnd()).toDays();
+ long last30dDays = java.time.Duration.between(window.last30dStart(), window.last30dEnd()).toDays();
+
+ assertAll(
+ () -> assertThat(last7dDays).isEqualTo(7),
+ () -> assertThat(last30dDays).isEqualTo(30),
+ () -> assertThat(window.last7dEnd()).isEqualTo(window.last30dEnd())
+ );
+ }
+
+ @DisplayName("LAST_7D / LAST_30D 상한은 anchor + 1일 00:00 으로 '오늘은 제외' 된다.")
+ @Test
+ void endBoundaryExcludesToday() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
+
+ LocalDateTime today0am = LocalDate.of(2026, 4, 15).atStartOfDay();
+ assertAll(
+ () -> assertThat(window.last7dEnd()).isEqualTo(today0am),
+ () -> assertThat(window.last30dEnd()).isEqualTo(today0am)
+ );
+ }
+
+ @DisplayName("월 경계를 걸쳐도 안전하게 계산된다 (음수 일자 없음).")
+ @Test
+ void handlesMonthCrossing() {
+ RollingWindow window = RollingWindowResolver.resolve("20260102");
+
+ assertAll(
+ () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2025, 12, 27, 0, 0)),
+ () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2025, 12, 4, 0, 0))
+ );
+ }
+
+ @DisplayName("anchorDate 가 비어 있으면 예외를 던진다.")
+ @Test
+ void rejectsBlankAnchor() {
+ assertAll(
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(null))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(""))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ );
+ }
+
+ @DisplayName("anchorDate 포맷이 yyyyMMdd 가 아니면 예외를 던진다.")
+ @Test
+ void rejectsMalformedAnchor() {
+ assertAll(
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("2026-04-14"))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("20260230"))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("abcdefgh"))
+ .isInstanceOf(IllegalArgumentException.class)
+ );
+ }
+}
From 69a51f31643be2c75778dcb1022f7e5f6987e635 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 16:59:00 +0900
Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=95=20=ED=85=8C?=
=?UTF-8?q?=EC=9D=B4=EB=B8=94=EA=B3=BC=20Step=200=20=EC=B4=88=EA=B8=B0?=
=?UTF-8?q?=ED=99=94=20Tasklet=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- StagingRankingAggregation (1차): period_type + period_key + product_id PK,
Step 1~3 이 metric 별 raw sum 을 UPSERT 할 공간
- StagingRankingScored (2차): weight_group 을 PK 에 포함, score DESC 정렬 인덱스.
Step 5 가 전체 상품 score 를 저장하는 격리 공간
(MV 는 TOP 100 만 진입시키기 위해 중간 상태를 여기에 둠)
- TruncateStagingTasklet: ExecutionContext.anchorDateKey 로 현재 anchor 의
row 만 DELETE (전체 TRUNCATE 아님, 다른 anchor 실행과 격리)
- RollingRankingJobConfig: Step 0 만 연결된 Job 스캐폴드 구성
(이후 커밋에서 Step 1~7 순차 추가)
- 통합 테스트 4종: 타겟 anchor 만 삭제/빈 상태 멱등/같은 anchor 반복 실행/
anchorDate 누락 시 Job FAIL
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 53 ++++++++
.../step/truncate/TruncateStagingTasklet.java | 47 +++++++
.../staging/StagingRankingAggregation.java | 53 ++++++++
.../staging/StagingRankingAggregationId.java | 34 +++++
.../StagingRankingAggregationRepository.java | 12 ++
.../ranking/staging/StagingRankingScored.java | 70 ++++++++++
.../staging/StagingRankingScoredId.java | 37 ++++++
.../StagingRankingScoredRepository.java | 12 ++
...tagingRankingAggregationJpaRepository.java | 20 +++
...agingRankingAggregationRepositoryImpl.java | 28 ++++
.../StagingRankingScoredJpaRepository.java | 20 +++
.../StagingRankingScoredRepositoryImpl.java | 28 ++++
.../TruncateStagingStepIntegrationTest.java | 120 ++++++++++++++++++
13 files changed, 534 insertions(+)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
new file mode 100644
index 0000000000..523f298636
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -0,0 +1,53 @@
+package com.loopers.batch.job.ranking;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.truncate.TruncateStagingTasklet;
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+/**
+ * 롤링 7일 / 30일 랭킹 배치 Job 구성.
+ * 현재 Step 0 (스테이징 초기화) 만 연결되어 있으며, 이후 커밋에서 Step 1~7 가 순차 추가된다.
+ */
+@Configuration
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
+@RequiredArgsConstructor
+public class RollingRankingJobConfig {
+
+ public static final String JOB_NAME = "rollingRankingJob";
+ public static final String STEP_TRUNCATE_STAGING = "truncateStagingStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final JobListener jobListener;
+ private final StepMonitorListener stepMonitorListener;
+ private final RankingJobParametersListener rankingJobParametersListener;
+ private final TruncateStagingTasklet truncateStagingTasklet;
+
+ @Bean(JOB_NAME)
+ public Job rollingRankingJob() {
+ return new JobBuilder(JOB_NAME, jobRepository)
+ .listener(jobListener)
+ .listener(rankingJobParametersListener)
+ .start(truncateStagingStep())
+ .build();
+ }
+
+ @Bean(STEP_TRUNCATE_STAGING)
+ public Step truncateStagingStep() {
+ return new StepBuilder(STEP_TRUNCATE_STAGING, jobRepository)
+ .tasklet(truncateStagingTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java
new file mode 100644
index 0000000000..56652bf809
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java
@@ -0,0 +1,47 @@
+package com.loopers.batch.job.ranking.step.truncate;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import com.loopers.domain.ranking.staging.StagingRankingScoredRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Step 0 — 현재 anchor 의 스테이징만 초기화한다 (전체 TRUNCATE 아님).
+ * 재실행 시 이전 시도의 잔재를 제거하여 멱등성 확보.
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class TruncateStagingTasklet implements Tasklet {
+
+ private final StagingRankingAggregationRepository aggregationRepository;
+ private final StagingRankingScoredRepository scoredRepository;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Override
+ @Transactional
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ int aggregationDeleted = aggregationRepository.deleteByPeriodKey(anchorDateKey);
+ int scoredDeleted = scoredRepository.deleteByPeriodKey(anchorDateKey);
+
+ log.info(
+ "[STEP=truncateStagingStep] anchorDateKey={} aggregationDeleted={} scoredDeleted={}",
+ anchorDateKey, aggregationDeleted, scoredDeleted
+ );
+
+ contribution.incrementWriteCount(aggregationDeleted + scoredDeleted);
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java
new file mode 100644
index 0000000000..c2c6f7f83e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.ranking.staging;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+/**
+ * 1차 스테이징 — 각 메트릭(view/like/order) 을 product 단위로 합산한 raw sum 보관소.
+ * Step 1~3 가 UPSERT 로 채우고 Step 5 가 읽어 간다.
+ */
+@Entity
+@Table(name = "staging_ranking_aggregation")
+@IdClass(StagingRankingAggregationId.class)
+@Getter
+public class StagingRankingAggregation {
+
+ @Id
+ @Column(name = "period_type", length = 16, nullable = false)
+ private String periodType;
+
+ @Id
+ @Column(name = "period_key", length = 8, nullable = false)
+ private String periodKey;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ protected StagingRankingAggregation() {
+ }
+
+ public StagingRankingAggregation(String periodType, String periodKey, Long productId,
+ long viewCount, long likeCount, long salesAmount) {
+ this.periodType = periodType;
+ this.periodKey = periodKey;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java
new file mode 100644
index 0000000000..397c1c21ea
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java
@@ -0,0 +1,34 @@
+package com.loopers.domain.ranking.staging;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+public class StagingRankingAggregationId implements Serializable {
+
+ private String periodType;
+ private String periodKey;
+ private Long productId;
+
+ public StagingRankingAggregationId() {
+ }
+
+ public StagingRankingAggregationId(String periodType, String periodKey, Long productId) {
+ this.periodType = periodType;
+ this.periodKey = periodKey;
+ this.productId = productId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof StagingRankingAggregationId that)) return false;
+ return Objects.equals(periodType, that.periodType)
+ && Objects.equals(periodKey, that.periodKey)
+ && Objects.equals(productId, that.productId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(periodType, periodKey, productId);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java
new file mode 100644
index 0000000000..76bd920740
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking.staging;
+
+public interface StagingRankingAggregationRepository {
+
+ // Command
+ StagingRankingAggregation save(StagingRankingAggregation entity);
+
+ int deleteByPeriodKey(String periodKey);
+
+ // Query
+ long countByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java
new file mode 100644
index 0000000000..fb2a6fbb07
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java
@@ -0,0 +1,70 @@
+package com.loopers.domain.ranking.staging;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+/**
+ * 2차 스테이징 — 1차 스테이징을 score 까지 계산해둔 전체 상품 격리 공간.
+ * MV 에 TOP 100 만 진입시키기 위해, "전체 상품 score" 라는 중간 상태를 MV 가 아니라 여기에 둔다.
+ * Step 5(Chunk) 가 채우고 Step 5b(Tasklet) 가 TOP 100 으로 MV 에 promote 한다.
+ */
+@Entity
+@Table(
+ name = "staging_ranking_scored",
+ indexes = @Index(
+ name = "idx_scored_sort",
+ columnList = "period_type, period_key, weight_group, score"
+ )
+)
+@IdClass(StagingRankingScoredId.class)
+@Getter
+public class StagingRankingScored {
+
+ @Id
+ @Column(name = "period_type", length = 16, nullable = false)
+ private String periodType;
+
+ @Id
+ @Column(name = "period_key", length = 8, nullable = false)
+ private String periodKey;
+
+ @Id
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ @Column(name = "score", nullable = false)
+ private double score;
+
+ protected StagingRankingScored() {
+ }
+
+ public StagingRankingScored(String periodType, String periodKey, String weightGroup, Long productId,
+ long viewCount, long likeCount, long salesAmount, double score) {
+ this.periodType = periodType;
+ this.periodKey = periodKey;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ this.score = score;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java
new file mode 100644
index 0000000000..a7c4c3744b
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java
@@ -0,0 +1,37 @@
+package com.loopers.domain.ranking.staging;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+public class StagingRankingScoredId implements Serializable {
+
+ private String periodType;
+ private String periodKey;
+ private String weightGroup;
+ private Long productId;
+
+ public StagingRankingScoredId() {
+ }
+
+ public StagingRankingScoredId(String periodType, String periodKey, String weightGroup, Long productId) {
+ this.periodType = periodType;
+ this.periodKey = periodKey;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof StagingRankingScoredId that)) return false;
+ return Objects.equals(periodType, that.periodType)
+ && Objects.equals(periodKey, that.periodKey)
+ && Objects.equals(weightGroup, that.weightGroup)
+ && Objects.equals(productId, that.productId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(periodType, periodKey, weightGroup, productId);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java
new file mode 100644
index 0000000000..ad4e61b860
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking.staging;
+
+public interface StagingRankingScoredRepository {
+
+ // Command
+ StagingRankingScored save(StagingRankingScored entity);
+
+ int deleteByPeriodKey(String periodKey);
+
+ // Query
+ long countByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java
new file mode 100644
index 0000000000..0cddd8538f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationId;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+interface StagingRankingAggregationJpaRepository
+ extends JpaRepository {
+
+ // Command
+ @Modifying
+ @Query("DELETE FROM StagingRankingAggregation s WHERE s.periodKey = :periodKey")
+ int deleteByPeriodKey(@Param("periodKey") String periodKey);
+
+ // Query
+ long countByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java
new file mode 100644
index 0000000000..1248a8fbcb
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class StagingRankingAggregationRepositoryImpl implements StagingRankingAggregationRepository {
+
+ private final StagingRankingAggregationJpaRepository jpaRepository;
+
+ @Override
+ public StagingRankingAggregation save(StagingRankingAggregation entity) {
+ return jpaRepository.save(entity);
+ }
+
+ @Override
+ public int deleteByPeriodKey(String periodKey) {
+ return jpaRepository.deleteByPeriodKey(periodKey);
+ }
+
+ @Override
+ public long countByPeriodKey(String periodKey) {
+ return jpaRepository.countByPeriodKey(periodKey);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java
new file mode 100644
index 0000000000..1219c17167
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import com.loopers.domain.ranking.staging.StagingRankingScoredId;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+interface StagingRankingScoredJpaRepository
+ extends JpaRepository {
+
+ // Command
+ @Modifying
+ @Query("DELETE FROM StagingRankingScored s WHERE s.periodKey = :periodKey")
+ int deleteByPeriodKey(@Param("periodKey") String periodKey);
+
+ // Query
+ long countByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java
new file mode 100644
index 0000000000..69cdfb86fc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import com.loopers.domain.ranking.staging.StagingRankingScoredRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class StagingRankingScoredRepositoryImpl implements StagingRankingScoredRepository {
+
+ private final StagingRankingScoredJpaRepository jpaRepository;
+
+ @Override
+ public StagingRankingScored save(StagingRankingScored entity) {
+ return jpaRepository.save(entity);
+ }
+
+ @Override
+ public int deleteByPeriodKey(String periodKey) {
+ return jpaRepository.deleteByPeriodKey(periodKey);
+ }
+
+ @Override
+ public long countByPeriodKey(String periodKey) {
+ return jpaRepository.countByPeriodKey(periodKey);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
new file mode 100644
index 0000000000..699977908f
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
@@ -0,0 +1,120 @@
+package com.loopers.batch.job.ranking.step.truncate;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import com.loopers.domain.ranking.staging.StagingRankingScoredRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest
+@SpringBatchTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class TruncateStagingStepIntegrationTest {
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private StagingRankingAggregationRepository aggregationRepository;
+ @Autowired private StagingRankingScoredRepository scoredRepository;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("anchorDateKey 에 해당하는 두 스테이징 테이블의 row 만 삭제된다.")
+ @Test
+ void deletesOnlyTargetAnchor() throws Exception {
+ String targetAnchor = "20260414";
+ String otherAnchor = "20260101";
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", targetAnchor, 1L, 10, 0, 0));
+ aggregationRepository.save(new StagingRankingAggregation("LAST_30D", targetAnchor, 2L, 20, 0, 0));
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", otherAnchor, 3L, 30, 0, 0));
+ scoredRepository.save(new StagingRankingScored("LAST_7D", targetAnchor, "control", 1L, 10, 0, 0, 1.0));
+ scoredRepository.save(new StagingRankingScored("LAST_30D", otherAnchor, "control", 3L, 30, 0, 0, 3.0));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(targetAnchor));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(targetAnchor)).isZero(),
+ () -> assertThat(scoredRepository.countByPeriodKey(targetAnchor)).isZero(),
+ () -> assertThat(aggregationRepository.countByPeriodKey(otherAnchor)).isOne(),
+ () -> assertThat(scoredRepository.countByPeriodKey(otherAnchor)).isOne()
+ );
+ }
+
+ @DisplayName("비어있는 스테이징에 실행해도 멱등하게 성공한다 (첫 실행 시나리오).")
+ @Test
+ void succeedsOnEmptyStaging() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf("20260414"));
+
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ }
+
+ @DisplayName("같은 anchorDate 로 두 번 돌려도 결과가 동일하다 (배치 멱등성).")
+ @Test
+ void idempotentOnRepeatedRun() throws Exception {
+ String anchor = "20260414";
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", anchor, 1L, 10, 0, 0));
+ scoredRepository.save(new StagingRankingScored("LAST_7D", anchor, "control", 1L, 10, 0, 0, 1.0));
+
+ jobLauncherTestUtils.setJob(job);
+
+ JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(anchor));
+ // 재실행을 위해 새 JobInstance 로 실행 (runTimestamp 로 격리)
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(anchor));
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(anchor)).isZero(),
+ () -> assertThat(scoredRepository.countByPeriodKey(anchor)).isZero()
+ );
+ }
+
+ @DisplayName("anchorDate 파라미터가 없으면 Job 이 실패한다.")
+ @Test
+ void failsWhenAnchorDateMissing() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+
+ JobExecution execution = jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder()
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters()
+ );
+
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED);
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
From c4f692bb2ce86914ec72162bc5a5e2a525d8bce7 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 20:17:23 +0900
Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20Step=201=20-=20View=20=EB=A9=94=ED=8A=B8=EB=A6=AD?=
=?UTF-8?q?=20cursor=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=A7=91?=
=?UTF-8?q?=EA=B3=84=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ProductViewMetric 원천 읽기 모델 (commerce-streamer 의 스키마 미러)
- AggregatedMetric / RawMetricRow DTO
- StreamingMetricAggregator: product_id 경계 감지 + 7d/30d 조건부 누적 O(1) 메모리
- ViewMetricStreamingReader: JdbcCursorItemReader (fetchSize 2000) + aggregator wrapper.
DB 는 bucket_time 범위 scan 만, GROUP BY 없음. 집계는 App 책임
- StagingAggregationProcessor: 1 Aggregated → 2 StagingRow (LAST_7D + LAST_30D) fan-out
- StagingViewMetricsWriter: JdbcTemplate batch UPSERT (ON DUPLICATE KEY UPDATE view_count)
- JPA saveAll() merge 함정 회피, 1차 캐시 OOM 방지
- StageViewMetricsStepConfig: chunkSize 500, 트랜잭션 경계 분리
- RollingRankingJobConfig: Step 0 → Step 1 체인
테스트:
- StreamingMetricAggregatorTest (5 단위): 경계 감지, 7d 경계 exclusive,
빈 소스, 재호출 안정성, 긴 체인 8640 row O(1) 검증
- StageViewMetricsStepIntegrationTest (3 통합): 범위 내외 필터, 멱등성,
빈 원천
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 8 +-
.../ranking/step/stage/AggregatedMetric.java | 8 +
.../job/ranking/step/stage/RawMetricRow.java | 10 ++
.../stage/StageViewMetricsStepConfig.java | 39 +++++
.../stage/StagingAggregationProcessor.java | 44 ++++++
.../step/stage/StagingViewMetricsWriter.java | 65 ++++++++
.../step/stage/StreamingMetricAggregator.java | 67 ++++++++
.../step/stage/ViewMetricStreamingReader.java | 88 +++++++++++
.../domain/metrics/ProductMetricId.java | 32 ++++
.../domain/metrics/ProductViewMetric.java | 48 ++++++
.../StageViewMetricsStepIntegrationTest.java | 145 ++++++++++++++++++
.../stage/StreamingMetricAggregatorTest.java | 124 +++++++++++++++
12 files changed, 676 insertions(+), 2 deletions(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 523f298636..9be356867f 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -1,6 +1,7 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.stage.StageViewMetricsStepConfig;
import com.loopers.batch.job.ranking.step.truncate.TruncateStagingTasklet;
import com.loopers.batch.listener.JobListener;
import com.loopers.batch.listener.StepMonitorListener;
@@ -10,6 +11,7 @@
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -17,7 +19,8 @@
/**
* 롤링 7일 / 30일 랭킹 배치 Job 구성.
- * 현재 Step 0 (스테이징 초기화) 만 연결되어 있으며, 이후 커밋에서 Step 1~7 가 순차 추가된다.
+ * 현재 Step 0 (스테이징 초기화) + Step 1 (View 적재) 가 연결되어 있으며,
+ * 이후 커밋에서 Step 2~7 가 순차 추가된다.
*/
@Configuration
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
@@ -35,11 +38,12 @@ public class RollingRankingJobConfig {
private final TruncateStagingTasklet truncateStagingTasklet;
@Bean(JOB_NAME)
- public Job rollingRankingJob() {
+ public Job rollingRankingJob(@Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep) {
return new JobBuilder(JOB_NAME, jobRepository)
.listener(jobListener)
.listener(rankingJobParametersListener)
.start(truncateStagingStep())
+ .next(stageViewMetricsStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java
new file mode 100644
index 0000000000..9704fce721
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java
@@ -0,0 +1,8 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+/**
+ * App streaming aggregator 가 product_id 경계마다 flush 하는 집계 결과.
+ * LAST_7D/LAST_30D 범위의 합산이 한 번의 cursor 스캔으로 동시에 계산된다.
+ */
+public record AggregatedMetric(Long productId, long sum7d, long sum30d) {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java
new file mode 100644
index 0000000000..e21cccc8fd
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java
@@ -0,0 +1,10 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import java.time.LocalDateTime;
+
+/**
+ * Cursor 로 한 줄씩 흘러오는 원시 메트릭 row.
+ * (product_id ASC, bucket_time ASC) 순서로 전달되어야 한다.
+ */
+public record RawMetricRow(Long productId, LocalDateTime bucketTime, long count) {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java
new file mode 100644
index 0000000000..e5c6bb1cba
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java
@@ -0,0 +1,39 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class StageViewMetricsStepConfig {
+
+ public static final String STEP_NAME = "stageViewMetricsStep";
+ private static final int CHUNK_SIZE = 500;
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final ViewMetricStreamingReader reader;
+ private final StagingAggregationProcessor processor;
+ private final StagingViewMetricsWriter writer;
+
+ @Bean(STEP_NAME)
+ public Step stageViewMetricsStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .>chunk(CHUNK_SIZE, transactionManager)
+ .reader(reader)
+ .processor(processor)
+ .writer(writer)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java
new file mode 100644
index 0000000000..4c7fde2c41
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java
@@ -0,0 +1,44 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * AggregatedMetric(productId, sum7d, sum30d) → {@code List} 2건.
+ *
+ * 순수 변환 (I/O 없음, 상태 없음). Chunk 철학의 fan-out 패턴:
+ * 각 output 이 서로 독립적이어야 하는데 LAST_7D / LAST_30D row 는 독립이므로 OK.
+ */
+@Component
+@StepScope
+public class StagingAggregationProcessor implements ItemProcessor> {
+
+ public static final String PERIOD_LAST_7D = "LAST_7D";
+ public static final String PERIOD_LAST_30D = "LAST_30D";
+
+ private final String anchorDateKey;
+
+ public StagingAggregationProcessor(
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey
+ ) {
+ this.anchorDateKey = anchorDateKey;
+ }
+
+ @Override
+ public List process(AggregatedMetric item) {
+ return List.of(
+ new StagingRankingAggregation(
+ PERIOD_LAST_7D, anchorDateKey, item.productId(),
+ item.sum7d(), 0L, 0L),
+ new StagingRankingAggregation(
+ PERIOD_LAST_30D, anchorDateKey, item.productId(),
+ item.sum30d(), 0L, 0L)
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java
new file mode 100644
index 0000000000..c4d0eb9cec
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java
@@ -0,0 +1,65 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Step 1 Writer — staging_ranking_aggregation 에 view_count 만 UPSERT.
+ *
+ * Step 2 (like), Step 3 (order) 는 각각 like_count / sales_amount 만 UPDATE 하므로,
+ * Step 1 의 INSERT 이후 해당 row 의 다른 컬럼은 0 으로 남아 있다가 Step 2/3 에서 채워진다.
+ *
+ * JPA {@code saveAll()} 대신 JDBC batch UPSERT 를 쓰는 이유는 설계.md 의
+ * "JPA Writer 함정" 섹션 참고: merge() 건별 SELECT 회피 + 1차 캐시 OOM 방지.
+ */
+@Component
+@RequiredArgsConstructor
+public class StagingViewMetricsWriter implements ItemWriter> {
+
+ private static final String UPSERT_SQL = """
+ INSERT INTO staging_ranking_aggregation
+ (period_type, period_key, product_id, view_count, like_count, sales_amount)
+ VALUES (?, ?, ?, ?, 0, 0)
+ ON DUPLICATE KEY UPDATE
+ view_count = VALUES(view_count)
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public void write(Chunk extends List> chunk) {
+ List flattened = new ArrayList<>();
+ for (List group : chunk) {
+ flattened.addAll(group);
+ }
+ if (flattened.isEmpty()) {
+ return;
+ }
+
+ jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() {
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+ StagingRankingAggregation row = flattened.get(i);
+ ps.setString(1, row.getPeriodType());
+ ps.setString(2, row.getPeriodKey());
+ ps.setLong(3, row.getProductId());
+ ps.setLong(4, row.getViewCount());
+ }
+
+ @Override
+ public int getBatchSize() {
+ return flattened.size();
+ }
+ });
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
new file mode 100644
index 0000000000..862672e4fa
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
@@ -0,0 +1,67 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+/**
+ * product_id 순서로 정렬된 raw row 들을 소비하며 product 경계마다 집계 결과를 내놓는다.
+ *
+ * DB 가 GROUP BY 를 수행하지 않고 App 에서 스트리밍 집계하는 이유는 프롤로그
+ * "배치의 본질 — 예측 가능성 > 평균" 원칙에서 도출된다.
+ * 입력이 N배 튀어도 본 로직은 정확히 N배 시간만 선형으로 늘어난다.
+ *
+ * 메모리는 한 상품의 누적 값 두 개(sum7d, sum30d) 만 유지한다 → O(1).
+ *
+ * 전제: source 는 {@code null} 로 끝점을 알리며, row 는 product_id 로 이미 정렬되어 있다.
+ */
+public final class StreamingMetricAggregator {
+
+ public interface RowSource {
+ RawMetricRow readOne() throws Exception;
+ }
+
+ private final RowSource source;
+ private final LocalDateTime last7dStart;
+ private RawMetricRow lookahead;
+ private boolean exhausted;
+
+ public StreamingMetricAggregator(RowSource source, LocalDateTime last7dStart) {
+ this.source = Objects.requireNonNull(source);
+ this.last7dStart = Objects.requireNonNull(last7dStart);
+ }
+
+ /**
+ * 다음 product 의 집계 결과를 반환하거나, 더 이상 없으면 {@code null}.
+ */
+ public AggregatedMetric next() throws Exception {
+ if (exhausted) {
+ return null;
+ }
+
+ RawMetricRow current = (lookahead != null) ? lookahead : source.readOne();
+ lookahead = null;
+ if (current == null) {
+ exhausted = true;
+ return null;
+ }
+
+ Long productId = current.productId();
+ long sum7d = 0L;
+ long sum30d = 0L;
+
+ while (current != null && productId.equals(current.productId())) {
+ sum30d += current.count();
+ if (!current.bucketTime().isBefore(last7dStart)) {
+ sum7d += current.count();
+ }
+ current = source.readOne();
+ }
+
+ // 다음 product 의 첫 row 를 lookahead 에 보관 (다음 next() 호출에서 사용)
+ lookahead = current;
+ if (current == null) {
+ exhausted = true;
+ }
+ return new AggregatedMetric(productId, sum7d, sum30d);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
new file mode 100644
index 0000000000..fdaacbdc99
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
@@ -0,0 +1,88 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ExecutionContext;
+import org.springframework.batch.item.ItemStreamException;
+import org.springframework.batch.item.ItemStreamReader;
+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.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+/**
+ * product_view_metrics 를 (product_id, bucket_time) 순 cursor 로 스트리밍 읽고,
+ * App 측 StreamingMetricAggregator 로 product 경계마다 AggregatedMetric 을 흘려보낸다.
+ *
+ * DB 는 단순 range scan 만 수행한다 (GROUP BY 없음). 집계는 App 책임.
+ */
+@Slf4j
+@Component
+@StepScope
+public class ViewMetricStreamingReader implements ItemStreamReader {
+
+ private static final int FETCH_SIZE = 2000;
+
+ private final JdbcCursorItemReader delegate;
+ private final LocalDateTime last7dStart;
+ private StreamingMetricAggregator aggregator;
+
+ public ViewMetricStreamingReader(
+ DataSource dataSource,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd
+ ) {
+ this.last7dStart = LocalDateTime.parse(last7dStart);
+ LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart);
+ LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd);
+
+ this.delegate = new JdbcCursorItemReaderBuilder()
+ .name("viewMetricCursorReader")
+ .dataSource(dataSource)
+ .fetchSize(FETCH_SIZE)
+ .sql("""
+ SELECT product_id, bucket_time, view_count
+ FROM product_view_metrics
+ WHERE bucket_time >= ?
+ AND bucket_time < ?
+ ORDER BY product_id, bucket_time
+ """)
+ .preparedStatementSetter((ps) -> {
+ ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime));
+ ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime));
+ })
+ .rowMapper((rs, rowNum) -> new RawMetricRow(
+ rs.getLong("product_id"),
+ rs.getTimestamp("bucket_time").toLocalDateTime(),
+ rs.getLong("view_count")
+ ))
+ .build();
+ }
+
+ @Override
+ public void open(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.open(executionContext);
+ this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+ }
+
+ @Override
+ public void update(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.update(executionContext);
+ }
+
+ @Override
+ public void close() throws ItemStreamException {
+ delegate.close();
+ }
+
+ @Override
+ public AggregatedMetric read() throws Exception {
+ return aggregator.next();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java
new file mode 100644
index 0000000000..dd91c9c273
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java
@@ -0,0 +1,32 @@
+package com.loopers.domain.metrics;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+public class ProductMetricId implements Serializable {
+
+ private Long productId;
+ private LocalDateTime bucketTime;
+
+ public ProductMetricId() {
+ }
+
+ public ProductMetricId(Long productId, LocalDateTime bucketTime) {
+ this.productId = productId;
+ this.bucketTime = bucketTime;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof ProductMetricId that)) return false;
+ return Objects.equals(productId, that.productId)
+ && Objects.equals(bucketTime, that.bucketTime);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(productId, bucketTime);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java
new file mode 100644
index 0000000000..1c34819318
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java
@@ -0,0 +1,48 @@
+package com.loopers.domain.metrics;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델.
+ *
+ * 이 엔티티는 배치가 테이블을 "읽기 위한 schema 정의" 로만 존재한다:
+ * - 프로덕션에서는 streamer 가 이 테이블을 소유하고 UPSERT 한다
+ * - 배치는 JdbcCursorItemReader 로 원시 row 만 스트리밍하여 App 에서 집계한다
+ * - 테스트 환경에서 ddl-auto 가 동일 스키마를 생성할 수 있도록 미러 엔티티를 둔다
+ */
+@Entity
+@Table(name = "product_view_metrics", indexes = {
+ @Index(name = "idx_pvm_bucket_time", columnList = "bucket_time")
+})
+@IdClass(ProductMetricId.class)
+@Getter
+public class ProductViewMetric {
+
+ @Id
+ @Column(name = "product_id")
+ private Long productId;
+
+ @Id
+ @Column(name = "bucket_time")
+ private LocalDateTime bucketTime;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ protected ProductViewMetric() {
+ }
+
+ public ProductViewMetric(Long productId, LocalDateTime bucketTime, long viewCount) {
+ this.productId = productId;
+ this.bucketTime = bucketTime;
+ this.viewCount = viewCount;
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
new file mode 100644
index 0000000000..8dffb4c5a1
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
@@ -0,0 +1,145 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest
+@SpringBatchTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class StageViewMetricsStepIntegrationTest {
+
+ private static final String ANCHOR = "20260414";
+ // anchor = 2026-04-14 → last7dStart = 2026-04-08, last30dStart = 2026-03-16, end = 2026-04-15T00:00
+ private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0);
+ private static final LocalDateTime IN_30D_ONLY = LocalDateTime.of(2026, 3, 20, 9, 0);
+ private static final LocalDateTime BEFORE_30D = LocalDateTime.of(2026, 3, 10, 0, 0); // 제외
+ private static final LocalDateTime ON_TODAY = LocalDateTime.of(2026, 4, 15, 0, 0); // 오늘 = 제외
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private StagingRankingAggregationRepository aggregationRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("anchor 범위 내 bucket 은 집계되고, 범위 밖 (30일 이전·오늘 이후) 은 제외된다.")
+ @Test
+ void aggregatesOnlyWithinWindow() throws Exception {
+ // product 1: 7d 10 + 30d 추가 5 = sum7d 10, sum30d 15
+ saveView(1L, IN_7D, 10L);
+ saveView(1L, IN_30D_ONLY, 5L);
+ // product 2: 30d only
+ saveView(2L, IN_30D_ONLY, 7L);
+ // 범위 밖 — 집계 제외
+ saveView(3L, BEFORE_30D, 100L);
+ saveView(3L, ON_TODAY, 100L);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(10L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(15L),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 2L)).isEqualTo(0L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 2L)).isEqualTo(7L),
+ () -> assertThat(productExists(ANCHOR, 3L)).isFalse(),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(4L) // (LAST_7D + LAST_30D) × 2 products
+ );
+ }
+
+ @DisplayName("같은 anchor 로 Job 을 다시 돌려도 결과가 동일하다 (멱등성).")
+ @Test
+ void idempotentOnRerun() throws Exception {
+ saveView(1L, IN_7D, 3L);
+ saveView(1L, IN_7D.plusHours(1), 4L);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(7L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(7L),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(2L)
+ );
+ }
+
+ @DisplayName("원천이 비어 있어도 Job 은 성공하고 staging 은 비어 있다.")
+ @Test
+ void emptySourceSucceeds() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isZero()
+ );
+ }
+
+ // -- helpers --
+
+ private void saveView(long productId, LocalDateTime bucketTime, long viewCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), viewCount
+ );
+ }
+
+ private long viewCount(String periodType, String periodKey, long productId) {
+ Long v = jdbcTemplate.queryForObject(
+ "SELECT view_count FROM staging_ranking_aggregation " +
+ " WHERE period_type=? AND period_key=? AND product_id=?",
+ Long.class, periodType, periodKey, productId
+ );
+ return v == null ? 0L : v;
+ }
+
+ private boolean productExists(String periodKey, long productId) {
+ Integer c = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM staging_ranking_aggregation " +
+ " WHERE period_key=? AND product_id=?",
+ Integer.class, periodKey, productId
+ );
+ return c != null && c > 0;
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
new file mode 100644
index 0000000000..eeaddde706
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
@@ -0,0 +1,124 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+class StreamingMetricAggregatorTest {
+
+ private static final LocalDateTime LAST_7D_START = LocalDateTime.of(2026, 4, 8, 0, 0);
+ private static final LocalDateTime LAST_30D_START = LocalDateTime.of(2026, 3, 16, 0, 0);
+
+ @DisplayName("같은 product 의 연속된 row 는 한 AggregatedMetric 으로 합쳐진다.")
+ @Test
+ void collapseSameProductRowsIntoOneAggregated() throws Exception {
+ ListSource source = new ListSource(List.of(
+ row(1L, LAST_7D_START, 5), // 7d 포함
+ row(1L, LAST_7D_START.plusDays(3), 10), // 7d 포함
+ row(2L, LAST_30D_START, 3) // 30d 만
+ ));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ AggregatedMetric first = agg.next();
+ AggregatedMetric second = agg.next();
+ AggregatedMetric end = agg.next();
+
+ assertAll(
+ () -> assertThat(first.productId()).isEqualTo(1L),
+ () -> assertThat(first.sum7d()).isEqualTo(15),
+ () -> assertThat(first.sum30d()).isEqualTo(15),
+ () -> assertThat(second.productId()).isEqualTo(2L),
+ () -> assertThat(second.sum7d()).isEqualTo(0),
+ () -> assertThat(second.sum30d()).isEqualTo(3),
+ () -> assertThat(end).isNull()
+ );
+ }
+
+ @DisplayName("bucket_time 이 last7dStart 보다 앞이면 sum7d 에는 포함되지 않고 sum30d 에만 포함된다.")
+ @Test
+ void boundaryExcludesPre7dFromSum7d() throws Exception {
+ ListSource source = new ListSource(List.of(
+ row(1L, LAST_30D_START, 7), // 30d O, 7d X
+ row(1L, LAST_7D_START.minusSeconds(1), 2), // 30d O, 7d X (경계 직전)
+ row(1L, LAST_7D_START, 3) // 30d O, 7d O (경계 포함)
+ ));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ AggregatedMetric result = agg.next();
+
+ assertAll(
+ () -> assertThat(result.sum30d()).isEqualTo(12),
+ () -> assertThat(result.sum7d()).isEqualTo(3)
+ );
+ }
+
+ @DisplayName("소스가 비어 있으면 첫 호출부터 null 을 반환한다.")
+ @Test
+ void emptySourceReturnsNullImmediately() throws Exception {
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(new ListSource(List.of()), LAST_7D_START);
+
+ assertThat(agg.next()).isNull();
+ }
+
+ @DisplayName("한 번 exhausted 되면 이후 호출도 항상 null 을 반환한다.")
+ @Test
+ void stablyReturnsNullAfterExhaustion() throws Exception {
+ ListSource source = new ListSource(List.of(row(1L, LAST_7D_START, 5)));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ assertAll(
+ () -> assertThat(agg.next()).isNotNull(),
+ () -> assertThat(agg.next()).isNull(),
+ () -> assertThat(agg.next()).isNull()
+ );
+ }
+
+ @DisplayName("한 상품이 많은 row (예: 8,640 개) 를 가져도 O(1) 메모리로 처리된다.")
+ @Test
+ void handlesLongChainWithConstantMemory() throws Exception {
+ int chainLength = 8_640;
+ ListSource source = new ListSource(generateChain(1L, chainLength));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ AggregatedMetric result = agg.next();
+
+ assertAll(
+ () -> assertThat(result.productId()).isEqualTo(1L),
+ () -> assertThat(result.sum30d()).isEqualTo(chainLength),
+ () -> assertThat(agg.next()).isNull()
+ );
+ }
+
+ private static RawMetricRow row(long productId, LocalDateTime bucketTime, long count) {
+ return new RawMetricRow(productId, bucketTime, count);
+ }
+
+ private static List generateChain(long productId, int size) {
+ List rows = new java.util.ArrayList<>(size);
+ for (int i = 0; i < size; i++) {
+ rows.add(row(productId, LAST_30D_START.plusMinutes(5L * i), 1));
+ }
+ return rows;
+ }
+
+ /** 테스트용 RowSource — List 를 큐로 소비한다. */
+ private static final class ListSource implements StreamingMetricAggregator.RowSource {
+ private final Deque queue;
+
+ ListSource(List rows) {
+ this.queue = new ArrayDeque<>(rows);
+ }
+
+ @Override
+ public RawMetricRow readOne() {
+ return queue.pollFirst();
+ }
+ }
+}
From bccd9b02555d10a5e066afe1485b9103b668a91d Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 20:24:38 +0900
Subject: [PATCH 04/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20Step=202/3=20-=20Like/Order=20=EB=A9=94=ED=8A=B8?=
=?UTF-8?q?=EB=A6=AD=20=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=95=20=EC=A0=81?=
=?UTF-8?q?=EC=9E=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
View 스테이징 패턴을 Like, Order 에도 동일하게 적용.
DB 는 테이블별 범위 scan 만, App streaming aggregator 로 product 경계 집계.
- ProductLikeMetric / ProductOrderMetric 원천 읽기 모델
- LikeMetricStreamingReader / OrderMetricStreamingReader: cursor + aggregator
- StagingLikeAggregationProcessor: AggregatedMetric → like_count 자리 fan-out
- StagingOrderAggregationProcessor: AggregatedMetric → sales_amount 자리 fan-out
- StagingLikeMetricsWriter / StagingOrderMetricsWriter:
ON DUPLICATE KEY UPDATE like_count / sales_amount 만 갱신
- Step 1 이 먼저 만든 row 는 UPSERT 의 UPDATE 가지,
Like/Order 만 있는 상품은 같은 쿼리의 INSERT 로 신규 row 생성
- StageLikeMetricsStepConfig / StageOrderMetricsStepConfig (chunk=500)
- RollingRankingJobConfig: Step 0 → 1 → 2 → 3 체인 확정
테스트 (StageMetricsPipelineIntegrationTest, 4 통합):
- 3 메트릭 모두 있는 상품의 단일 row 병합
- Like only / Order only 상품의 INSERT-then-UPDATE 경로
- 메트릭별 독립 상품 집합 동시 적재
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 10 +-
.../step/stage/LikeMetricStreamingReader.java | 84 +++++++++
.../stage/OrderMetricStreamingReader.java | 84 +++++++++
.../stage/StageLikeMetricsStepConfig.java | 39 ++++
.../stage/StageOrderMetricsStepConfig.java | 39 ++++
.../StagingLikeAggregationProcessor.java | 38 ++++
.../step/stage/StagingLikeMetricsWriter.java | 60 +++++++
.../StagingOrderAggregationProcessor.java | 38 ++++
.../step/stage/StagingOrderMetricsWriter.java | 59 +++++++
.../domain/metrics/ProductLikeMetric.java | 44 +++++
.../domain/metrics/ProductOrderMetric.java | 53 ++++++
.../StageMetricsPipelineIntegrationTest.java | 167 ++++++++++++++++++
12 files changed, 714 insertions(+), 1 deletion(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 9be356867f..53825ffe04 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -1,6 +1,8 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig;
+import com.loopers.batch.job.ranking.step.stage.StageOrderMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageViewMetricsStepConfig;
import com.loopers.batch.job.ranking.step.truncate.TruncateStagingTasklet;
import com.loopers.batch.listener.JobListener;
@@ -38,12 +40,18 @@ public class RollingRankingJobConfig {
private final TruncateStagingTasklet truncateStagingTasklet;
@Bean(JOB_NAME)
- public Job rollingRankingJob(@Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep) {
+ public Job rollingRankingJob(
+ @Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep,
+ @Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep,
+ @Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep
+ ) {
return new JobBuilder(JOB_NAME, jobRepository)
.listener(jobListener)
.listener(rankingJobParametersListener)
.start(truncateStagingStep())
.next(stageViewMetricsStep)
+ .next(stageLikeMetricsStep)
+ .next(stageOrderMetricsStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
new file mode 100644
index 0000000000..7385d0b4fc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
@@ -0,0 +1,84 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ExecutionContext;
+import org.springframework.batch.item.ItemStreamException;
+import org.springframework.batch.item.ItemStreamReader;
+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.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+/**
+ * product_like_metrics 를 cursor 로 스트리밍 + App 집계.
+ * {@link ViewMetricStreamingReader} 와 동일한 패턴이며 테이블·컬럼만 다르다.
+ */
+@Component
+@StepScope
+public class LikeMetricStreamingReader implements ItemStreamReader {
+
+ private static final int FETCH_SIZE = 2000;
+
+ private final JdbcCursorItemReader delegate;
+ private final LocalDateTime last7dStart;
+ private StreamingMetricAggregator aggregator;
+
+ public LikeMetricStreamingReader(
+ DataSource dataSource,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd
+ ) {
+ this.last7dStart = LocalDateTime.parse(last7dStart);
+ LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart);
+ LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd);
+
+ this.delegate = new JdbcCursorItemReaderBuilder()
+ .name("likeMetricCursorReader")
+ .dataSource(dataSource)
+ .fetchSize(FETCH_SIZE)
+ .sql("""
+ SELECT product_id, bucket_time, like_count
+ FROM product_like_metrics
+ WHERE bucket_time >= ?
+ AND bucket_time < ?
+ ORDER BY product_id, bucket_time
+ """)
+ .preparedStatementSetter((ps) -> {
+ ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime));
+ ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime));
+ })
+ .rowMapper((rs, rowNum) -> new RawMetricRow(
+ rs.getLong("product_id"),
+ rs.getTimestamp("bucket_time").toLocalDateTime(),
+ rs.getLong("like_count")
+ ))
+ .build();
+ }
+
+ @Override
+ public void open(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.open(executionContext);
+ this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+ }
+
+ @Override
+ public void update(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.update(executionContext);
+ }
+
+ @Override
+ public void close() throws ItemStreamException {
+ delegate.close();
+ }
+
+ @Override
+ public AggregatedMetric read() throws Exception {
+ return aggregator.next();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
new file mode 100644
index 0000000000..a886d25e04
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
@@ -0,0 +1,84 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ExecutionContext;
+import org.springframework.batch.item.ItemStreamException;
+import org.springframework.batch.item.ItemStreamReader;
+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.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+/**
+ * product_order_metrics 의 sales_amount 를 cursor 로 스트리밍 + App 집계.
+ * 랭킹 스코어에 쓰이는 것은 salesAmount 이므로 여기서는 그 컬럼만 집계 대상으로 삼는다.
+ */
+@Component
+@StepScope
+public class OrderMetricStreamingReader implements ItemStreamReader {
+
+ private static final int FETCH_SIZE = 2000;
+
+ private final JdbcCursorItemReader delegate;
+ private final LocalDateTime last7dStart;
+ private StreamingMetricAggregator aggregator;
+
+ public OrderMetricStreamingReader(
+ DataSource dataSource,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart,
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd
+ ) {
+ this.last7dStart = LocalDateTime.parse(last7dStart);
+ LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart);
+ LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd);
+
+ this.delegate = new JdbcCursorItemReaderBuilder()
+ .name("orderMetricCursorReader")
+ .dataSource(dataSource)
+ .fetchSize(FETCH_SIZE)
+ .sql("""
+ SELECT product_id, bucket_time, sales_amount
+ FROM product_order_metrics
+ WHERE bucket_time >= ?
+ AND bucket_time < ?
+ ORDER BY product_id, bucket_time
+ """)
+ .preparedStatementSetter((ps) -> {
+ ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime));
+ ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime));
+ })
+ .rowMapper((rs, rowNum) -> new RawMetricRow(
+ rs.getLong("product_id"),
+ rs.getTimestamp("bucket_time").toLocalDateTime(),
+ rs.getLong("sales_amount")
+ ))
+ .build();
+ }
+
+ @Override
+ public void open(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.open(executionContext);
+ this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+ }
+
+ @Override
+ public void update(ExecutionContext executionContext) throws ItemStreamException {
+ delegate.update(executionContext);
+ }
+
+ @Override
+ public void close() throws ItemStreamException {
+ delegate.close();
+ }
+
+ @Override
+ public AggregatedMetric read() throws Exception {
+ return aggregator.next();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java
new file mode 100644
index 0000000000..6601c7350c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java
@@ -0,0 +1,39 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class StageLikeMetricsStepConfig {
+
+ public static final String STEP_NAME = "stageLikeMetricsStep";
+ private static final int CHUNK_SIZE = 500;
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final LikeMetricStreamingReader reader;
+ private final StagingLikeAggregationProcessor processor;
+ private final StagingLikeMetricsWriter writer;
+
+ @Bean(STEP_NAME)
+ public Step stageLikeMetricsStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .>chunk(CHUNK_SIZE, transactionManager)
+ .reader(reader)
+ .processor(processor)
+ .writer(writer)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java
new file mode 100644
index 0000000000..8c969ec4b9
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java
@@ -0,0 +1,39 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class StageOrderMetricsStepConfig {
+
+ public static final String STEP_NAME = "stageOrderMetricsStep";
+ private static final int CHUNK_SIZE = 500;
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final OrderMetricStreamingReader reader;
+ private final StagingOrderAggregationProcessor processor;
+ private final StagingOrderMetricsWriter writer;
+
+ @Bean(STEP_NAME)
+ public Step stageOrderMetricsStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .>chunk(CHUNK_SIZE, transactionManager)
+ .reader(reader)
+ .processor(processor)
+ .writer(writer)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java
new file mode 100644
index 0000000000..1103f594c9
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java
@@ -0,0 +1,38 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Step 2 전용 — AggregatedMetric → like_count 자리에 sum 을 채운 2 건의 fan-out.
+ */
+@Component
+@StepScope
+public class StagingLikeAggregationProcessor implements ItemProcessor> {
+
+ private final String anchorDateKey;
+
+ public StagingLikeAggregationProcessor(
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey
+ ) {
+ this.anchorDateKey = anchorDateKey;
+ }
+
+ @Override
+ public List process(AggregatedMetric item) {
+ return List.of(
+ new StagingRankingAggregation(
+ StagingAggregationProcessor.PERIOD_LAST_7D, anchorDateKey, item.productId(),
+ 0L, item.sum7d(), 0L),
+ new StagingRankingAggregation(
+ StagingAggregationProcessor.PERIOD_LAST_30D, anchorDateKey, item.productId(),
+ 0L, item.sum30d(), 0L)
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java
new file mode 100644
index 0000000000..e3e85870ab
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java
@@ -0,0 +1,60 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Step 2 Writer — staging_ranking_aggregation 의 like_count 만 UPSERT.
+ * Step 1 이 INSERT 로 row 를 먼저 만든 상태에서 기존 row 의 like_count 컬럼만 갱신한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class StagingLikeMetricsWriter implements ItemWriter> {
+
+ private static final String UPSERT_SQL = """
+ INSERT INTO staging_ranking_aggregation
+ (period_type, period_key, product_id, view_count, like_count, sales_amount)
+ VALUES (?, ?, ?, 0, ?, 0)
+ ON DUPLICATE KEY UPDATE
+ like_count = VALUES(like_count)
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public void write(Chunk extends List> chunk) {
+ List flattened = new ArrayList<>();
+ for (List group : chunk) {
+ flattened.addAll(group);
+ }
+ if (flattened.isEmpty()) {
+ return;
+ }
+
+ jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() {
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+ StagingRankingAggregation row = flattened.get(i);
+ ps.setString(1, row.getPeriodType());
+ ps.setString(2, row.getPeriodKey());
+ ps.setLong(3, row.getProductId());
+ ps.setLong(4, row.getLikeCount());
+ }
+
+ @Override
+ public int getBatchSize() {
+ return flattened.size();
+ }
+ });
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java
new file mode 100644
index 0000000000..afa14ebe2f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java
@@ -0,0 +1,38 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * Step 3 전용 — AggregatedMetric → sales_amount 자리에 sum 을 채운 2 건의 fan-out.
+ */
+@Component
+@StepScope
+public class StagingOrderAggregationProcessor implements ItemProcessor> {
+
+ private final String anchorDateKey;
+
+ public StagingOrderAggregationProcessor(
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey
+ ) {
+ this.anchorDateKey = anchorDateKey;
+ }
+
+ @Override
+ public List process(AggregatedMetric item) {
+ return List.of(
+ new StagingRankingAggregation(
+ StagingAggregationProcessor.PERIOD_LAST_7D, anchorDateKey, item.productId(),
+ 0L, 0L, item.sum7d()),
+ new StagingRankingAggregation(
+ StagingAggregationProcessor.PERIOD_LAST_30D, anchorDateKey, item.productId(),
+ 0L, 0L, item.sum30d())
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java
new file mode 100644
index 0000000000..92930c40ff
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java
@@ -0,0 +1,59 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Step 3 Writer — staging_ranking_aggregation 의 sales_amount 만 UPSERT.
+ */
+@Component
+@RequiredArgsConstructor
+public class StagingOrderMetricsWriter implements ItemWriter> {
+
+ private static final String UPSERT_SQL = """
+ INSERT INTO staging_ranking_aggregation
+ (period_type, period_key, product_id, view_count, like_count, sales_amount)
+ VALUES (?, ?, ?, 0, 0, ?)
+ ON DUPLICATE KEY UPDATE
+ sales_amount = VALUES(sales_amount)
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public void write(Chunk extends List> chunk) {
+ List flattened = new ArrayList<>();
+ for (List group : chunk) {
+ flattened.addAll(group);
+ }
+ if (flattened.isEmpty()) {
+ return;
+ }
+
+ jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() {
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+ StagingRankingAggregation row = flattened.get(i);
+ ps.setString(1, row.getPeriodType());
+ ps.setString(2, row.getPeriodKey());
+ ps.setLong(3, row.getProductId());
+ ps.setLong(4, row.getSalesAmount());
+ }
+
+ @Override
+ public int getBatchSize() {
+ return flattened.size();
+ }
+ });
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java
new file mode 100644
index 0000000000..457856560d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.metrics;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델.
+ * ProductViewMetric 의 주석 참고.
+ */
+@Entity
+@Table(name = "product_like_metrics", indexes = {
+ @Index(name = "idx_plm_bucket_time", columnList = "bucket_time")
+})
+@IdClass(ProductMetricId.class)
+@Getter
+public class ProductLikeMetric {
+
+ @Id
+ @Column(name = "product_id")
+ private Long productId;
+
+ @Id
+ @Column(name = "bucket_time")
+ private LocalDateTime bucketTime;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ protected ProductLikeMetric() {
+ }
+
+ public ProductLikeMetric(Long productId, LocalDateTime bucketTime, long likeCount) {
+ this.productId = productId;
+ this.bucketTime = bucketTime;
+ this.likeCount = likeCount;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java
new file mode 100644
index 0000000000..3d4c591a42
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.metrics;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델.
+ * ProductViewMetric 의 주석 참고.
+ */
+@Entity
+@Table(name = "product_order_metrics", indexes = {
+ @Index(name = "idx_pom_bucket_time", columnList = "bucket_time")
+})
+@IdClass(ProductMetricId.class)
+@Getter
+public class ProductOrderMetric {
+
+ @Id
+ @Column(name = "product_id")
+ private Long productId;
+
+ @Id
+ @Column(name = "bucket_time")
+ private LocalDateTime bucketTime;
+
+ @Column(name = "order_count", nullable = false)
+ private int orderCount;
+
+ @Column(name = "quantity", nullable = false)
+ private long quantity;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ protected ProductOrderMetric() {
+ }
+
+ public ProductOrderMetric(Long productId, LocalDateTime bucketTime,
+ int orderCount, long quantity, long salesAmount) {
+ this.productId = productId;
+ this.bucketTime = bucketTime;
+ this.orderCount = orderCount;
+ this.quantity = quantity;
+ this.salesAmount = salesAmount;
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
new file mode 100644
index 0000000000..c4e07eff00
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
@@ -0,0 +1,167 @@
+package com.loopers.batch.job.ranking.step.stage;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+/**
+ * Step 1 (View) + Step 2 (Like) + Step 3 (Order) 의 파이프라인 검증.
+ * 서로 다른 메트릭의 UPSERT 가 같은 staging row 에 올바르게 합쳐지는지,
+ * 한 메트릭만 있는 상품도 정상 처리되는지 확인한다.
+ */
+@SpringBootTest
+@SpringBatchTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class StageMetricsPipelineIntegrationTest {
+
+ private static final String ANCHOR = "20260414";
+ private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0);
+ private static final LocalDateTime IN_30D_ONLY = LocalDateTime.of(2026, 3, 20, 9, 0);
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private StagingRankingAggregationRepository aggregationRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("3 메트릭(View/Like/Order) 이 모두 있는 상품은 staging row 하나에 세 컬럼이 모두 채워진다.")
+ @Test
+ void mergesThreeMetricsIntoOneRow() throws Exception {
+ saveView(1L, IN_7D, 10);
+ saveView(1L, IN_30D_ONLY, 5);
+ saveLike(1L, IN_7D, 2);
+ saveLike(1L, IN_30D_ONLY, 3);
+ saveOrder(1L, IN_7D, 1000);
+ saveOrder(1L, IN_30D_ONLY, 2000);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 1L)).containsExactly(10L, 2L, 1000L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 1L)).containsExactly(15L, 5L, 3000L)
+ );
+ }
+
+ @DisplayName("Like 만 있는 상품은 Step 2 의 INSERT 로 row 가 새로 생성되고 view/sales 는 0 이다.")
+ @Test
+ void likeOnlyProductIsInsertedByStep2() throws Exception {
+ saveLike(2L, IN_7D, 4);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L)
+ );
+ }
+
+ @DisplayName("Order 만 있는 상품도 Step 3 의 INSERT 로 row 가 생성된다.")
+ @Test
+ void orderOnlyProductIsInsertedByStep3() throws Exception {
+ saveOrder(3L, IN_30D_ONLY, 5000);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 3L)).containsExactly(0L, 0L, 0L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 3L)).containsExactly(0L, 0L, 5000L)
+ );
+ }
+
+ @DisplayName("메트릭마다 다른 상품 집합이 있어도 각각 독립적으로 적재된다.")
+ @Test
+ void independentProductsPerMetric() throws Exception {
+ saveView(10L, IN_7D, 1);
+ saveLike(20L, IN_7D, 2);
+ saveOrder(30L, IN_7D, 3);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 10L)).containsExactly(1L, 0L, 0L),
+ () -> assertThat(row("LAST_7D", ANCHOR, 20L)).containsExactly(0L, 2L, 0L),
+ () -> assertThat(row("LAST_7D", ANCHOR, 30L)).containsExactly(0L, 0L, 3L),
+ // product 당 LAST_7D + LAST_30D → 3 × 2 = 6
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(6L)
+ );
+ }
+
+ // -- helpers --
+
+ private void saveView(long productId, LocalDateTime bucketTime, long viewCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), viewCount
+ );
+ }
+
+ private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), likeCount
+ );
+ }
+
+ private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " +
+ "VALUES (?, ?, 1, 1, ?)",
+ productId, Timestamp.valueOf(bucketTime), salesAmount
+ );
+ }
+
+ /** (view_count, like_count, sales_amount) 을 배열로 반환. */
+ private Long[] row(String periodType, String periodKey, long productId) {
+ return jdbcTemplate.queryForObject(
+ "SELECT view_count, like_count, sales_amount FROM staging_ranking_aggregation " +
+ " WHERE period_type=? AND period_key=? AND product_id=?",
+ (rs, rn) -> new Long[]{rs.getLong(1), rs.getLong(2), rs.getLong(3)},
+ periodType, periodKey, productId
+ );
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
From ebe14ce952626bb45656403de84acb95e4b1e18e Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 20:38:36 +0900
Subject: [PATCH 05/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20Step=204a/4b=20-=20MV=20=EC=82=AC=EC=A0=84=20DELETE?=
=?UTF-8?q?=20Tasklet=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 5b 가 INSERT 하기 전에 "MV 는 비어있음" 상태를 보장하여,
MV 가 거쳐가는 상태를 "비어있음 → 확정된 TOP 100" 두 가지로만 제한한다.
조회 API 가 배치 도중 MV 를 읽더라도 정의된 상태만 보게 됨 (중간 상태 불가시성).
- MvProductRankId 공통 PK (anchor_date, weight_group, product_id)
- MvProductRankLast7d / MvProductRankLast30d 엔티티 + rank_position 인덱스
- 도메인 Repository 인터페이스 + RepositoryImpl + package-private JpaRepository
- PurgeLast7dMvTasklet / PurgeLast30dMvTasklet (@StepScope, anchorDateKey → LocalDate)
- PurgeMvStepConfig: Step 4a/4b bean 등록
- RollingRankingJobConfig: Step 0 → 1 → 2 → 3 → 4a → 4b 체인
통합 테스트 (PurgeMvStepIntegrationTest, 3):
- 타겟 anchor 만 양쪽 MV 에서 삭제, 다른 anchor 유지
- 동일 anchor 의 weight_group 여러 개를 전부 삭제
- 빈 MV 상태에서도 성공 (첫 실행 시나리오)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 7 +-
.../step/purge/PurgeLast30dMvTasklet.java | 47 +++++++
.../step/purge/PurgeLast7dMvTasklet.java | 48 +++++++
.../ranking/step/purge/PurgeMvStepConfig.java | 40 ++++++
.../domain/ranking/mv/MvProductRankId.java | 39 ++++++
.../ranking/mv/MvProductRankLast30d.java | 75 +++++++++++
.../mv/MvProductRankLast30dRepository.java | 14 ++
.../ranking/mv/MvProductRankLast7d.java | 81 ++++++++++++
.../mv/MvProductRankLast7dRepository.java | 14 ++
.../MvProductRankLast30dJpaRepository.java | 22 ++++
.../MvProductRankLast30dRepositoryImpl.java | 30 +++++
.../MvProductRankLast7dJpaRepository.java | 22 ++++
.../MvProductRankLast7dRepositoryImpl.java | 30 +++++
.../purge/PurgeMvStepIntegrationTest.java | 120 ++++++++++++++++++
14 files changed, 588 insertions(+), 1 deletion(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 53825ffe04..d6eabb0827 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -1,6 +1,7 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.purge.PurgeMvStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageOrderMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageViewMetricsStepConfig;
@@ -43,7 +44,9 @@ public class RollingRankingJobConfig {
public Job rollingRankingJob(
@Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep,
@Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep,
- @Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep
+ @Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
+ @Qualifier(PurgeMvStepConfig.STEP_LAST_7D) Step purgeLast7dMvStep,
+ @Qualifier(PurgeMvStepConfig.STEP_LAST_30D) Step purgeLast30dMvStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
.listener(jobListener)
@@ -52,6 +55,8 @@ public Job rollingRankingJob(
.next(stageViewMetricsStep)
.next(stageLikeMetricsStep)
.next(stageOrderMetricsStep)
+ .next(purgeLast7dMvStep)
+ .next(purgeLast30dMvStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
new file mode 100644
index 0000000000..13f55246de
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
@@ -0,0 +1,47 @@
+package com.loopers.batch.job.ranking.step.purge;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Step 4b — 현재 anchorDate 의 LAST_30D MV row 를 사전 DELETE.
+ * {@link PurgeLast7dMvTasklet} 주석 참고.
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class PurgeLast30dMvTasklet implements Tasklet {
+
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ private final MvProductRankLast30dRepository repository;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Override
+ @Transactional
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ int deleted = repository.deleteByAnchorDate(anchorDate);
+
+ log.info("[STEP=purgeLast30dMvStep] anchorDate={} deleted={}", anchorDate, deleted);
+
+ contribution.incrementWriteCount(deleted);
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java
new file mode 100644
index 0000000000..ebafaa04a1
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java
@@ -0,0 +1,48 @@
+package com.loopers.batch.job.ranking.step.purge;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Step 4a — 현재 anchorDate 의 LAST_7D MV row 를 사전 DELETE 한다.
+ * Step 5b 의 INSERT 가 돌기 전에 "MV 는 비어있음" 상태를 보장하여,
+ * MV 가 거쳐가는 상태를 "비어있음 → 확정된 TOP 100" 두 가지로만 제한한다 (중간 상태 불가시성).
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class PurgeLast7dMvTasklet implements Tasklet {
+
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ private final MvProductRankLast7dRepository repository;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Override
+ @Transactional
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ int deleted = repository.deleteByAnchorDate(anchorDate);
+
+ log.info("[STEP=purgeLast7dMvStep] anchorDate={} deleted={}", anchorDate, deleted);
+
+ contribution.incrementWriteCount(deleted);
+ return RepeatStatus.FINISHED;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
new file mode 100644
index 0000000000..7579e64789
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
@@ -0,0 +1,40 @@
+package com.loopers.batch.job.ranking.step.purge;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@Configuration
+@RequiredArgsConstructor
+public class PurgeMvStepConfig {
+
+ public static final String STEP_LAST_7D = "purgeLast7dMvStep";
+ public static final String STEP_LAST_30D = "purgeLast30dMvStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final PurgeLast7dMvTasklet purgeLast7dMvTasklet;
+ private final PurgeLast30dMvTasklet purgeLast30dMvTasklet;
+
+ @Bean(STEP_LAST_7D)
+ public Step purgeLast7dMvStep() {
+ return new StepBuilder(STEP_LAST_7D, jobRepository)
+ .tasklet(purgeLast7dMvTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+
+ @Bean(STEP_LAST_30D)
+ public Step purgeLast30dMvStep() {
+ return new StepBuilder(STEP_LAST_30D, jobRepository)
+ .tasklet(purgeLast30dMvTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
new file mode 100644
index 0000000000..f4d2b11cd4
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
@@ -0,0 +1,39 @@
+package com.loopers.domain.ranking.mv;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.util.Objects;
+
+/**
+ * mv_product_rank_last_7d / mv_product_rank_last_30d 공통 PK 클래스.
+ * 자연 키: (anchor_date, weight_group, product_id)
+ */
+public class MvProductRankId implements Serializable {
+
+ private LocalDate anchorDate;
+ private String weightGroup;
+ private Long productId;
+
+ public MvProductRankId() {
+ }
+
+ public MvProductRankId(LocalDate anchorDate, String weightGroup, Long productId) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof MvProductRankId that)) return false;
+ return Objects.equals(anchorDate, that.anchorDate)
+ && Objects.equals(weightGroup, that.weightGroup)
+ && Objects.equals(productId, that.productId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(anchorDate, weightGroup, productId);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
new file mode 100644
index 0000000000..6b85008e5a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
@@ -0,0 +1,75 @@
+package com.loopers.domain.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 롤링 30일 랭킹 확정 MV. {@link MvProductRankLast7d} 주석 참고.
+ */
+@Entity
+@Table(
+ name = "mv_product_rank_last_30d",
+ indexes = @Index(
+ name = "idx_last_30d_rank",
+ columnList = "anchor_date, weight_group, rank_position"
+ )
+)
+@IdClass(MvProductRankId.class)
+@Getter
+public class MvProductRankLast30d {
+
+ @Id
+ @Column(name = "anchor_date", nullable = false)
+ private LocalDate anchorDate;
+
+ @Id
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ @Column(name = "score", nullable = false)
+ private double score;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rankPosition;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ protected MvProductRankLast30d() {
+ }
+
+ public MvProductRankLast30d(LocalDate anchorDate, String weightGroup, Long productId,
+ long viewCount, long likeCount, long salesAmount,
+ double score, int rankPosition, LocalDateTime createdAt) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ this.score = score;
+ this.rankPosition = rankPosition;
+ this.createdAt = createdAt;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java
new file mode 100644
index 0000000000..1a62676bd3
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.ranking.mv;
+
+import java.time.LocalDate;
+
+public interface MvProductRankLast30dRepository {
+
+ // Command
+ MvProductRankLast30d save(MvProductRankLast30d entity);
+
+ int deleteByAnchorDate(LocalDate anchorDate);
+
+ // Query
+ long countByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
new file mode 100644
index 0000000000..86d8cf0019
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
@@ -0,0 +1,81 @@
+package com.loopers.domain.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 롤링 7일 랭킹 확정 MV — TOP 100 만 저장되는 조회 전용 영속화 테이블.
+ *
+ * 배치 도중에는 MV 에 "비어있음" 또는 "확정된 TOP 100" 두 상태만 존재하도록 설계됐다
+ * (중간 상태 불가시성, 설계.md 프롤로그 "확정됨(committed)" 원칙).
+ *
+ * Step 4a 가 해당 anchor_date 의 row 를 사전 DELETE 하고,
+ * Step 5b 가 Top 100 을 단일 SQL INSERT 로 채운다.
+ */
+@Entity
+@Table(
+ name = "mv_product_rank_last_7d",
+ indexes = @Index(
+ name = "idx_last_7d_rank",
+ columnList = "anchor_date, weight_group, rank_position"
+ )
+)
+@IdClass(MvProductRankId.class)
+@Getter
+public class MvProductRankLast7d {
+
+ @Id
+ @Column(name = "anchor_date", nullable = false)
+ private LocalDate anchorDate;
+
+ @Id
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ @Column(name = "score", nullable = false)
+ private double score;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rankPosition;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ protected MvProductRankLast7d() {
+ }
+
+ public MvProductRankLast7d(LocalDate anchorDate, String weightGroup, Long productId,
+ long viewCount, long likeCount, long salesAmount,
+ double score, int rankPosition, LocalDateTime createdAt) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ this.score = score;
+ this.rankPosition = rankPosition;
+ this.createdAt = createdAt;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java
new file mode 100644
index 0000000000..047923e956
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.ranking.mv;
+
+import java.time.LocalDate;
+
+public interface MvProductRankLast7dRepository {
+
+ // Command
+ MvProductRankLast7d save(MvProductRankLast7d entity);
+
+ int deleteByAnchorDate(LocalDate anchorDate);
+
+ // Query
+ long countByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java
new file mode 100644
index 0000000000..7d37faae23
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java
@@ -0,0 +1,22 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.mv.MvProductRankId;
+import com.loopers.domain.ranking.mv.MvProductRankLast30d;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDate;
+
+interface MvProductRankLast30dJpaRepository
+ extends JpaRepository {
+
+ // Command
+ @Modifying
+ @Query("DELETE FROM MvProductRankLast30d m WHERE m.anchorDate = :anchorDate")
+ int deleteByAnchorDate(@Param("anchorDate") LocalDate anchorDate);
+
+ // Query
+ long countByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java
new file mode 100644
index 0000000000..9c39c491b4
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java
@@ -0,0 +1,30 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.mv.MvProductRankLast30d;
+import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+
+@Repository
+@RequiredArgsConstructor
+public class MvProductRankLast30dRepositoryImpl implements MvProductRankLast30dRepository {
+
+ private final MvProductRankLast30dJpaRepository jpaRepository;
+
+ @Override
+ public MvProductRankLast30d save(MvProductRankLast30d entity) {
+ return jpaRepository.save(entity);
+ }
+
+ @Override
+ public int deleteByAnchorDate(LocalDate anchorDate) {
+ return jpaRepository.deleteByAnchorDate(anchorDate);
+ }
+
+ @Override
+ public long countByAnchorDate(LocalDate anchorDate) {
+ return jpaRepository.countByAnchorDate(anchorDate);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java
new file mode 100644
index 0000000000..f79de26439
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java
@@ -0,0 +1,22 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.mv.MvProductRankId;
+import com.loopers.domain.ranking.mv.MvProductRankLast7d;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDate;
+
+interface MvProductRankLast7dJpaRepository
+ extends JpaRepository {
+
+ // Command
+ @Modifying
+ @Query("DELETE FROM MvProductRankLast7d m WHERE m.anchorDate = :anchorDate")
+ int deleteByAnchorDate(@Param("anchorDate") LocalDate anchorDate);
+
+ // Query
+ long countByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java
new file mode 100644
index 0000000000..6564ad2107
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java
@@ -0,0 +1,30 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.mv.MvProductRankLast7d;
+import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+
+@Repository
+@RequiredArgsConstructor
+public class MvProductRankLast7dRepositoryImpl implements MvProductRankLast7dRepository {
+
+ private final MvProductRankLast7dJpaRepository jpaRepository;
+
+ @Override
+ public MvProductRankLast7d save(MvProductRankLast7d entity) {
+ return jpaRepository.save(entity);
+ }
+
+ @Override
+ public int deleteByAnchorDate(LocalDate anchorDate) {
+ return jpaRepository.deleteByAnchorDate(anchorDate);
+ }
+
+ @Override
+ public long countByAnchorDate(LocalDate anchorDate) {
+ return jpaRepository.countByAnchorDate(anchorDate);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
new file mode 100644
index 0000000000..f847f43709
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
@@ -0,0 +1,120 @@
+package com.loopers.batch.job.ranking.step.purge;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.mv.MvProductRankLast30d;
+import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
+import com.loopers.domain.ranking.mv.MvProductRankLast7d;
+import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest
+@SpringBatchTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class PurgeMvStepIntegrationTest {
+
+ private static final LocalDate TARGET_ANCHOR = LocalDate.of(2026, 4, 14);
+ private static final LocalDate OTHER_ANCHOR = LocalDate.of(2026, 4, 13);
+ private static final String ANCHOR_KEY = "20260414";
+ private static final LocalDateTime CREATED = LocalDateTime.of(2026, 4, 15, 1, 0);
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private MvProductRankLast7dRepository last7dRepository;
+ @Autowired private MvProductRankLast30dRepository last30dRepository;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("타겟 anchorDate 의 MV row 만 양쪽 테이블에서 삭제되고 다른 anchor 는 유지된다.")
+ @Test
+ void purgesOnlyTargetAnchorInBothMvTables() throws Exception {
+ last7dRepository.save(mv7d(TARGET_ANCHOR, 1L, 1));
+ last7dRepository.save(mv7d(OTHER_ANCHOR, 2L, 1));
+ last30dRepository.save(mv30d(TARGET_ANCHOR, 1L, 1));
+ last30dRepository.save(mv30d(OTHER_ANCHOR, 2L, 1));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
+ () -> assertThat(last7dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne(),
+ () -> assertThat(last30dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne()
+ );
+ }
+
+ @DisplayName("MV 에 동일 anchor/같은 상품이 여러 weight_group 에 있으면 그룹 구분 없이 전부 삭제된다.")
+ @Test
+ void purgesAllWeightGroupsForAnchor() throws Exception {
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "control", 1L, 1));
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_a", 1L, 1));
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_b", 1L, 1));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero()
+ );
+ }
+
+ @DisplayName("MV 가 비어 있어도 Job 은 성공한다 (첫 실행 시나리오).")
+ @Test
+ void succeedsOnEmptyMv() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ }
+
+ // -- helpers --
+
+ private static MvProductRankLast7d mv7d(LocalDate anchor, long productId, int rank) {
+ return mv7d(anchor, "control", productId, rank);
+ }
+
+ private static MvProductRankLast7d mv7d(LocalDate anchor, String group, long productId, int rank) {
+ return new MvProductRankLast7d(anchor, group, productId, 0, 0, 0, 0.0, rank, CREATED);
+ }
+
+ private static MvProductRankLast30d mv30d(LocalDate anchor, long productId, int rank) {
+ return new MvProductRankLast30d(anchor, "control", productId, 0, 0, 0, 0.0, rank, CREATED);
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
From 8ca5d0c74015a40610a2cd9e958b517d2aa15064 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 20:49:28 +0900
Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20Step=205=20-=20=EC=A0=84=EC=B2=B4=20=EC=83=81?=
=?UTF-8?q?=ED=92=88=20score=20=EA=B3=84=EC=82=B0=EC=9D=84=202=EC=B0=A8=20?=
=?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=95=EC=97=90=20=EC=A0=81?=
=?UTF-8?q?=EC=9E=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 5 는 1차 스테이징의 raw sum 에 weight_group 별 score 를 계산해
2차 스테이징(staging_ranking_scored) 에 전체 상품을 적재한다.
MV 는 건드리지 않으며, Step 5b 가 이 2차 스테이징에서 TOP 100 만 MV 로 promote 한다.
- WeightConfig 엔티티 미러 + Repository (ranking_weight_config)
활성 그룹이 없으면 "control" 기본값으로 fallback (streamer RankingAggregator 일관)
- ScoreFormula: score = w_v×v + w_l×l + w_o×log10(sales+1) 순수 함수.
sales 는 금액 단위라 log10 정규화로 다른 지표를 압도하지 않게 함
- ScoreProcessor (@StepScope): @BeforeStep 에서 활성 WeightConfig 로드 후
row 당 activeConfigs.size() 만큼 fan-out, Step 내 DB 조회 없음 (Bulk)
- StagingScoredWriter: JdbcTemplate batch UPSERT — 재시작 시 같은 PK 덮어쓰기
- ScoreAggregationStepConfig: 1차 staging cursor (fetchSize 2000) + chunk 500
- RollingRankingJobConfig: Step 0 → 1 → 2 → 3 → 4a → 4b → 5 체인
테스트:
- ScoreFormulaTest (4 단위): 가중 합 공식, log10(0+1)=0 안전성,
sales 의 log 정규화, 가중치별 score 발산
- ScoreAggregationStepIntegrationTest (3 통합): 단일 그룹 fan-out + 공식 검증,
여러 그룹 독립 score + inactive 제외, 빈 원천 성공
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 5 +-
.../score/ScoreAggregationStepConfig.java | 75 ++++++++
.../job/ranking/step/score/ScoreFormula.java | 25 +++
.../ranking/step/score/ScoreProcessor.java | 65 +++++++
.../step/score/StagingScoredWriter.java | 69 +++++++
.../domain/ranking/weight/WeightConfig.java | 64 +++++++
.../weight/WeightConfigRepository.java | 12 ++
.../ranking/WeightConfigJpaRepository.java | 11 ++
.../ranking/WeightConfigRepositoryImpl.java | 25 +++
.../ScoreAggregationStepIntegrationTest.java | 177 ++++++++++++++++++
.../ranking/step/score/ScoreFormulaTest.java | 54 ++++++
11 files changed, 581 insertions(+), 1 deletion(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index d6eabb0827..43a4527111 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -2,6 +2,7 @@
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.batch.job.ranking.step.purge.PurgeMvStepConfig;
+import com.loopers.batch.job.ranking.step.score.ScoreAggregationStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageOrderMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageViewMetricsStepConfig;
@@ -46,7 +47,8 @@ public Job rollingRankingJob(
@Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep,
@Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
@Qualifier(PurgeMvStepConfig.STEP_LAST_7D) Step purgeLast7dMvStep,
- @Qualifier(PurgeMvStepConfig.STEP_LAST_30D) Step purgeLast30dMvStep
+ @Qualifier(PurgeMvStepConfig.STEP_LAST_30D) Step purgeLast30dMvStep,
+ @Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
.listener(jobListener)
@@ -57,6 +59,7 @@ public Job rollingRankingJob(
.next(stageOrderMetricsStep)
.next(purgeLast7dMvStep)
.next(purgeLast30dMvStep)
+ .next(scoreAggregationStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
new file mode 100644
index 0000000000..a99a04f684
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
@@ -0,0 +1,75 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.listener.StepMonitorListener;
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+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.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import javax.sql.DataSource;
+import java.util.List;
+
+@Configuration
+@RequiredArgsConstructor
+public class ScoreAggregationStepConfig {
+
+ public static final String STEP_NAME = "scoreAggregationStep";
+ private static final int CHUNK_SIZE = 500;
+ private static final int FETCH_SIZE = 2000;
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final ScoreProcessor scoreProcessor;
+ private final StagingScoredWriter stagingScoredWriter;
+ private final DataSource dataSource;
+
+ @Bean
+ @StepScope
+ public JdbcCursorItemReader stagingAggregationCursorReader(
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey
+ ) {
+ return new JdbcCursorItemReaderBuilder()
+ .name("stagingAggregationCursorReader")
+ .dataSource(dataSource)
+ .fetchSize(FETCH_SIZE)
+ .sql("""
+ SELECT period_type, period_key, product_id,
+ view_count, like_count, sales_amount
+ FROM staging_ranking_aggregation
+ WHERE period_key = ?
+ ORDER BY period_type, product_id
+ """)
+ .preparedStatementSetter((ps) -> ps.setString(1, anchorDateKey))
+ .rowMapper((rs, rowNum) -> new StagingRankingAggregation(
+ rs.getString("period_type"),
+ rs.getString("period_key"),
+ rs.getLong("product_id"),
+ rs.getLong("view_count"),
+ rs.getLong("like_count"),
+ rs.getLong("sales_amount")
+ ))
+ .build();
+ }
+
+ @Bean(STEP_NAME)
+ public Step scoreAggregationStep(JdbcCursorItemReader stagingAggregationCursorReader) {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .>chunk(CHUNK_SIZE, transactionManager)
+ .reader(stagingAggregationCursorReader)
+ .processor(scoreProcessor)
+ .writer(stagingScoredWriter)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java
new file mode 100644
index 0000000000..c77176ba09
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java
@@ -0,0 +1,25 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.domain.ranking.weight.WeightConfig;
+
+/**
+ * 랭킹 스코어 공식 — 순수 함수.
+ * {@code score = w_view × viewCount + w_like × likeCount + w_order × log10(salesAmount + 1)}
+ *
+ * sales_amount 는 금액 단위라 view/like 카운트에 비해 스케일이 크므로 log10 으로 정규화한다
+ * (설계 히스토리: "주문 스코어링에 log10(salesAmount) 정규화 적용" 커밋).
+ *
+ * 변환 로직은 이 한 곳에 격리되어 있어 가중치·수식 변경 시 이 클래스만 수정하면 된다
+ * (설계.md "변경이 자주 있을 부분은 갈아끼울 수 있게" 원칙).
+ */
+public final class ScoreFormula {
+
+ private ScoreFormula() {
+ }
+
+ public static double compute(long viewCount, long likeCount, long salesAmount, WeightConfig config) {
+ return config.getWView() * viewCount
+ + config.getWLike() * likeCount
+ + config.getWOrder() * Math.log10(salesAmount + 1.0);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
new file mode 100644
index 0000000000..3defd92c0e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
@@ -0,0 +1,65 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.domain.ranking.staging.StagingRankingAggregation;
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.StepExecution;
+import org.springframework.batch.core.annotation.BeforeStep;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * StagingRankingAggregation 1건을 받아 활성 weight_group 수만큼 fan-out 한 StagingRankingScored 를 반환한다.
+ *
+ * @BeforeStep 에서 WeightConfig 를 한 번 로드하여 Step 내내 DB 조회 없음 (Bulk).
+ * score 는 WeightConfig 별로 다르게 계산된다 → A/B 테스트 그룹 지원.
+ */
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class ScoreProcessor implements ItemProcessor> {
+
+ private final WeightConfigRepository weightConfigRepository;
+
+ /**
+ * 활성화된 weight_group 이 하나도 없을 때 사용하는 기본 설정.
+ * streamer {@code RankingAggregator} 와 일관되게 "control" 그룹 기본값으로 fallback.
+ */
+ private static final WeightConfig DEFAULT_CONFIG =
+ new WeightConfig("control", 0.1, 0.2, 0.7, 100, true);
+
+ private List activeConfigs;
+
+ @BeforeStep
+ public void loadWeightConfigs(StepExecution stepExecution) {
+ List loaded = weightConfigRepository.findAllByActiveTrue();
+ this.activeConfigs = loaded.isEmpty() ? List.of(DEFAULT_CONFIG) : loaded;
+ }
+
+ @Override
+ public List process(StagingRankingAggregation item) {
+ List fanOut = new ArrayList<>(activeConfigs.size());
+ for (WeightConfig config : activeConfigs) {
+ double score = ScoreFormula.compute(
+ item.getViewCount(), item.getLikeCount(), item.getSalesAmount(), config
+ );
+ fanOut.add(new StagingRankingScored(
+ item.getPeriodType(),
+ item.getPeriodKey(),
+ config.getGroupName(),
+ item.getProductId(),
+ item.getViewCount(),
+ item.getLikeCount(),
+ item.getSalesAmount(),
+ score
+ ));
+ }
+ return fanOut;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
new file mode 100644
index 0000000000..c08b815948
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
@@ -0,0 +1,69 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.domain.ranking.staging.StagingRankingScored;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.item.Chunk;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Step 5 Writer — staging_ranking_scored 에 전체 상품 score 적재.
+ * Step 0 가 해당 anchor 의 scored row 를 비워 놓으므로 여기서는 순수 INSERT.
+ * 재시작 안전성을 위해 UPSERT 로 기록 (같은 PK 재실행 시 값 갱신).
+ */
+@Component
+@RequiredArgsConstructor
+public class StagingScoredWriter implements ItemWriter> {
+
+ private static final String UPSERT_SQL = """
+ INSERT INTO staging_ranking_scored
+ (period_type, period_key, weight_group, product_id,
+ view_count, like_count, sales_amount, score)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ view_count = VALUES(view_count),
+ like_count = VALUES(like_count),
+ sales_amount = VALUES(sales_amount),
+ score = VALUES(score)
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public void write(Chunk extends List> chunk) {
+ List flattened = new ArrayList<>();
+ for (List group : chunk) {
+ flattened.addAll(group);
+ }
+ if (flattened.isEmpty()) {
+ return;
+ }
+
+ jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() {
+ @Override
+ public void setValues(PreparedStatement ps, int i) throws SQLException {
+ StagingRankingScored row = flattened.get(i);
+ ps.setString(1, row.getPeriodType());
+ ps.setString(2, row.getPeriodKey());
+ ps.setString(3, row.getWeightGroup());
+ ps.setLong(4, row.getProductId());
+ ps.setLong(5, row.getViewCount());
+ ps.setLong(6, row.getLikeCount());
+ ps.setLong(7, row.getSalesAmount());
+ ps.setDouble(8, row.getScore());
+ }
+
+ @Override
+ public int getBatchSize() {
+ return flattened.size();
+ }
+ });
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java
new file mode 100644
index 0000000000..95604440fc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java
@@ -0,0 +1,64 @@
+package com.loopers.domain.ranking.weight;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.ZonedDateTime;
+
+/**
+ * commerce-api / commerce-streamer 가 공유하는 랭킹 가중치 설정의 배치 측 읽기 모델.
+ * 배치는 @BeforeStep 에서 active=true 인 행들만 로드하여 Processor 에서 그룹별 fan-out 에 사용한다.
+ */
+@Entity
+@Table(name = "ranking_weight_config")
+@Getter
+public class WeightConfig {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "group_name", nullable = false, unique = true, length = 50)
+ private String groupName;
+
+ @Column(name = "w_view", nullable = false)
+ private double wView;
+
+ @Column(name = "w_like", nullable = false)
+ private double wLike;
+
+ @Column(name = "w_order", nullable = false)
+ private double wOrder;
+
+ @Column(name = "traffic_pct", nullable = false)
+ private int trafficPct;
+
+ @Column(name = "active", nullable = false)
+ private boolean active;
+
+ @Column(name = "created_at", nullable = false)
+ private ZonedDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private ZonedDateTime updatedAt;
+
+ protected WeightConfig() {
+ }
+
+ public WeightConfig(String groupName, double wView, double wLike, double wOrder,
+ int trafficPct, boolean active) {
+ this.groupName = groupName;
+ this.wView = wView;
+ this.wLike = wLike;
+ this.wOrder = wOrder;
+ this.trafficPct = trafficPct;
+ this.active = active;
+ this.createdAt = ZonedDateTime.now();
+ this.updatedAt = ZonedDateTime.now();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java
new file mode 100644
index 0000000000..1041c27136
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.ranking.weight;
+
+import java.util.List;
+
+public interface WeightConfigRepository {
+
+ // Command (test fixture 용)
+ WeightConfig save(WeightConfig entity);
+
+ // Query
+ List findAllByActiveTrue();
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java
new file mode 100644
index 0000000000..e9166b0ab3
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.weight.WeightConfig;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+interface WeightConfigJpaRepository extends JpaRepository {
+
+ List findAllByActiveTrue();
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java
new file mode 100644
index 0000000000..bf807be75d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java
@@ -0,0 +1,25 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+@RequiredArgsConstructor
+public class WeightConfigRepositoryImpl implements WeightConfigRepository {
+
+ private final WeightConfigJpaRepository jpaRepository;
+
+ @Override
+ public WeightConfig save(WeightConfig entity) {
+ return jpaRepository.save(entity);
+ }
+
+ @Override
+ public List findAllByActiveTrue() {
+ return jpaRepository.findAllByActiveTrue();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
new file mode 100644
index 0000000000..7edee7d53e
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
@@ -0,0 +1,177 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.offset;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+/**
+ * Step 5 — 1차 staging 의 raw sum 에 score 를 계산해 2차 staging 에 적재하는 파이프라인 검증.
+ * Step 0~3 으로 1차가 먼저 채워지므로, 이 테스트는 view/like/order 원천에 시드하고
+ * Job 전체를 돌려 Step 5 결과만 확인한다.
+ */
+@SpringBootTest
+@SpringBatchTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class ScoreAggregationStepIntegrationTest {
+
+ private static final String ANCHOR = "20260414";
+ private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0);
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private WeightConfigRepository weightConfigRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("활성 weight_group 별로 2차 staging 에 row 가 fan-out 되고 score 가 공식대로 계산된다.")
+ @Test
+ void fansOutPerWeightGroupWithCorrectScore() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ double expectedScore = ScoreFormula.compute(
+ 100, 50, 999,
+ new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)
+ );
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // LAST_7D + LAST_30D × control 1 group = 2 rows
+ () -> assertThat(scoredCount(ANCHOR)).isEqualTo(2L),
+ () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9)),
+ () -> assertThat(scoreOf("LAST_30D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9))
+ );
+ }
+
+ @DisplayName("여러 weight_group 이 활성화되어 있으면 각 그룹별로 독립적인 score 가 저장된다.")
+ @Test
+ void multipleWeightGroupsProduceIndependentScores() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
+ weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
+ weightConfigRepository.save(new WeightConfig("inactive", 0.3, 0.3, 0.4, 0, false)); // 제외
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 100);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // (LAST_7D + LAST_30D) × (control + experiment_a) = 4 rows, inactive 제외
+ () -> assertThat(scoredCount(ANCHOR)).isEqualTo(4L),
+ () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L))
+ .isNotEqualTo(scoreOf("LAST_7D", ANCHOR, "experiment_a", 1L)),
+ () -> assertThat(existsScored(ANCHOR, "inactive")).isFalse()
+ );
+ }
+
+ @DisplayName("원천이 비어 있으면 1차/2차 staging 모두 비어 있고 Job 은 성공한다.")
+ @Test
+ void emptySourceProducesEmptyScored() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(scoredCount(ANCHOR)).isZero()
+ );
+ }
+
+ // -- helpers --
+
+ private void saveView(long productId, LocalDateTime bucketTime, long viewCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), viewCount
+ );
+ }
+
+ private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), likeCount
+ );
+ }
+
+ private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " +
+ "VALUES (?, ?, 1, 1, ?)",
+ productId, Timestamp.valueOf(bucketTime), salesAmount
+ );
+ }
+
+ private long scoredCount(String periodKey) {
+ Long c = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM staging_ranking_scored WHERE period_key = ?",
+ Long.class, periodKey
+ );
+ return c == null ? 0L : c;
+ }
+
+ private double scoreOf(String periodType, String periodKey, String group, long productId) {
+ Double s = jdbcTemplate.queryForObject(
+ "SELECT score FROM staging_ranking_scored " +
+ " WHERE period_type=? AND period_key=? AND weight_group=? AND product_id=?",
+ Double.class, periodType, periodKey, group, productId
+ );
+ return s == null ? 0.0 : s;
+ }
+
+ private boolean existsScored(String periodKey, String group) {
+ Integer c = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM staging_ranking_scored WHERE period_key=? AND weight_group=?",
+ Integer.class, periodKey, group
+ );
+ return c != null && c > 0;
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
new file mode 100644
index 0000000000..39b7261212
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
@@ -0,0 +1,54 @@
+package com.loopers.batch.job.ranking.step.score;
+
+import com.loopers.domain.ranking.weight.WeightConfig;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.offset;
+
+class ScoreFormulaTest {
+
+ private static final WeightConfig DEFAULT = new WeightConfig("control", 0.1, 0.2, 0.7, 100, true);
+
+ @DisplayName("score = w_view × view + w_like × like + w_order × log10(sales + 1)")
+ @Test
+ void computesWeightedSum() {
+ // w_view=0.1, w_like=0.2, w_order=0.7
+ // view=100, like=50, sales=999
+ // → 0.1*100 + 0.2*50 + 0.7*log10(1000) = 10 + 10 + 2.1 = 22.1
+ double score = ScoreFormula.compute(100, 50, 999, DEFAULT);
+
+ assertThat(score).isCloseTo(22.1, offset(1e-9));
+ }
+
+ @DisplayName("sales=0 이어도 log10(1)=0 으로 안전하게 계산된다 (log10(0) 회피).")
+ @Test
+ void handlesZeroSalesSafely() {
+ double score = ScoreFormula.compute(0, 0, 0, DEFAULT);
+
+ assertThat(score).isEqualTo(0.0);
+ }
+
+ @DisplayName("view/like 는 선형, sales 는 log 스케일이라 큰 금액도 다른 지표를 압도하지 않는다.")
+ @Test
+ void salesIsLogNormalized() {
+ // sales 1_000_000 → log10(1_000_001) ≈ 6
+ // 0.7 * 6 ≈ 4.2 (view 42 나 like 21 과 동급)
+ double scoreHighSales = ScoreFormula.compute(0, 0, 1_000_000, DEFAULT);
+
+ assertThat(scoreHighSales).isCloseTo(0.7 * 6, offset(0.001));
+ }
+
+ @DisplayName("weight 가 다른 두 config 는 같은 입력에 다른 score 를 만든다.")
+ @Test
+ void weightDrivesDivergentScores() {
+ WeightConfig viewHeavy = new WeightConfig("a", 0.8, 0.1, 0.1, 50, true);
+ WeightConfig orderHeavy = new WeightConfig("b", 0.1, 0.1, 0.8, 50, true);
+
+ double s1 = ScoreFormula.compute(100, 100, 100, viewHeavy);
+ double s2 = ScoreFormula.compute(100, 100, 100, orderHeavy);
+
+ assertThat(s1).isNotEqualTo(s2);
+ }
+}
From 97e453a375e8233d618a7710fadc86aa1110a5c6 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 21:02:27 +0900
Subject: [PATCH 07/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20Step=205b/7/6=20-=20Promote/Audit/Redis=20=EB=A1=9C?=
=?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=99=84?=
=?UTF-8?q?=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
롤링 7일/30일 랭킹 배치의 마지막 세 Step 을 추가해 Job 을 완성한다.
Step 5b (PromoteTopToMvTasklet):
- 2차 스테이징 → MV 로 TOP 100 을 단일 SQL INSERT (CTE + ROW_NUMBER)
- (period_type × weight_group) 조합 당 한 번의 쿼리
- Step 4a/4b 의 사전 DELETE 와 맞물려 MV 는 "비어있음 → 확정된 TOP 100"
두 상태만 통과 (중간 상태 불가시성)
Step 7 (AuditTasklet, batch_audit_log):
- 불변조건 검증: rank 1..count 연속, product_id 중복 없음, count ≤ TOP_N
- 실패 시 Job FAIL → Step 6 (Redis 전파) 차단
- BatchAuditLog 에 OK/FAILED 기록 — "Job COMPLETED 인데 데이터가 틀림" 감지
- CHECKSUM 은 의도적으로 미도입 (랭킹은 금융이 아니므로 과잉)
Step 6 (RedisRefreshTasklet):
- MV → Redis ZSET identity cache copy (score·순서 그대로)
- shadow key + RENAME 으로 원자적 교체, TTL 3일
- ResourcelessTransactionManager (Redis 조작은 DB 트랜잭션 불필요)
RollingRankingJobConfig:
- 0 → 1 → 2 → 3 → 4a → 4b → 5 → 5b → 7 → 6 최종 체인
테스트:
- RollingRankingJobE2ETest (4): 전체 파이프라인 + identity cache,
다중 weight_group 독립 Redis 키, 같은 anchor 재실행 멱등성, 빈 원천
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 11 +-
.../ranking/step/audit/AuditStepConfig.java | 30 +++
.../job/ranking/step/audit/AuditTasklet.java | 145 ++++++++++++
.../promote/PromoteTopToMvStepConfig.java | 30 +++
.../step/promote/PromoteTopToMvTasklet.java | 112 ++++++++++
.../step/redis/RedisRefreshStepConfig.java | 30 +++
.../step/redis/RedisRefreshTasklet.java | 115 ++++++++++
.../domain/ranking/audit/BatchAuditLog.java | 93 ++++++++
.../audit/BatchAuditLogRepository.java | 13 ++
.../ranking/BatchAuditLogJpaRepository.java | 12 +
.../ranking/BatchAuditLogRepositoryImpl.java | 26 +++
.../job/ranking/RollingRankingJobE2ETest.java | 211 ++++++++++++++++++
12 files changed, 827 insertions(+), 1 deletion(-)
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java
create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 43a4527111..83845ba122 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -1,7 +1,10 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.audit.AuditStepConfig;
+import com.loopers.batch.job.ranking.step.promote.PromoteTopToMvStepConfig;
import com.loopers.batch.job.ranking.step.purge.PurgeMvStepConfig;
+import com.loopers.batch.job.ranking.step.redis.RedisRefreshStepConfig;
import com.loopers.batch.job.ranking.step.score.ScoreAggregationStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageOrderMetricsStepConfig;
@@ -48,7 +51,10 @@ public Job rollingRankingJob(
@Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
@Qualifier(PurgeMvStepConfig.STEP_LAST_7D) Step purgeLast7dMvStep,
@Qualifier(PurgeMvStepConfig.STEP_LAST_30D) Step purgeLast30dMvStep,
- @Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep
+ @Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep,
+ @Qualifier(PromoteTopToMvStepConfig.STEP_NAME) Step promoteTopToMvStep,
+ @Qualifier(AuditStepConfig.STEP_NAME) Step auditStep,
+ @Qualifier(RedisRefreshStepConfig.STEP_NAME) Step redisRefreshStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
.listener(jobListener)
@@ -60,6 +66,9 @@ public Job rollingRankingJob(
.next(purgeLast7dMvStep)
.next(purgeLast30dMvStep)
.next(scoreAggregationStep)
+ .next(promoteTopToMvStep)
+ .next(auditStep)
+ .next(redisRefreshStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
new file mode 100644
index 0000000000..0a7d3609b8
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
@@ -0,0 +1,30 @@
+package com.loopers.batch.job.ranking.step.audit;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@Configuration
+@RequiredArgsConstructor
+public class AuditStepConfig {
+
+ public static final String STEP_NAME = "auditStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final AuditTasklet auditTasklet;
+
+ @Bean(STEP_NAME)
+ public Step auditStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(auditTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
new file mode 100644
index 0000000000..4693a59378
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
@@ -0,0 +1,145 @@
+package com.loopers.batch.job.ranking.step.audit;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor;
+import com.loopers.domain.ranking.audit.BatchAuditLog;
+import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.sql.Date;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Step 7 — MV 가 "정의된 상태 (정확히 TOP 100)" 인지 불변조건을 검증한다.
+ *
+ * 검증 항목:
+ *
+ * - count = 100 (TOP 100 완전 적재)
+ * - MIN(rank_position) = 1, MAX = 100 (1~100 연속)
+ * - DISTINCT product_id count = 100 (중복 없음)
+ *
+ * 위반 시 Job FAIL → Step 6 (Redis 전파) 차단. 잘못된 MV 가 캐시로 퍼지는 경로를 원천 차단.
+ *
+ * CHECKSUM (값 해시 비교) 은 의도적으로 도입하지 않음.
+ * 랭킹은 "돈이 잘못 움직이지 않는" 도메인이며, score 값 버그는 테스트 코드가 잡을 영역.
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class AuditTasklet implements Tasklet {
+
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final int EXPECTED_COUNT = 100;
+
+ private static final String AUDIT_SQL_TEMPLATE = """
+ SELECT COUNT(*) AS row_count,
+ COALESCE(MIN(rank_position), 0) AS min_rank,
+ COALESCE(MAX(rank_position), 0) AS max_rank,
+ COUNT(DISTINCT product_id) AS distinct_products
+ FROM %s
+ WHERE anchor_date = ?
+ AND weight_group = ?
+ """;
+
+ private static final String AUDIT_SQL_LAST_7D = AUDIT_SQL_TEMPLATE.formatted("mv_product_rank_last_7d");
+ private static final String AUDIT_SQL_LAST_30D = AUDIT_SQL_TEMPLATE.formatted("mv_product_rank_last_30d");
+
+ private final JdbcTemplate jdbcTemplate;
+ private final WeightConfigRepository weightConfigRepository;
+ private final BatchAuditLogRepository auditLogRepository;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Value("#{stepExecution.jobExecution.id}")
+ private Long jobExecutionId;
+
+ @Override
+ @Transactional
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ List configs = weightConfigRepository.findAllByActiveTrue();
+ if (configs.isEmpty()) {
+ configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+
+ List failures = new ArrayList<>();
+ for (WeightConfig config : configs) {
+ failures.addAll(auditPeriod(
+ anchorDate, StagingAggregationProcessor.PERIOD_LAST_7D, config.getGroupName(), AUDIT_SQL_LAST_7D));
+ failures.addAll(auditPeriod(
+ anchorDate, StagingAggregationProcessor.PERIOD_LAST_30D, config.getGroupName(), AUDIT_SQL_LAST_30D));
+ }
+
+ if (!failures.isEmpty()) {
+ String message = "MV audit 실패: " + String.join(" / ", failures);
+ log.error("[STEP=auditStep] FAILED anchorDate={} reasons={}", anchorDate, failures);
+ throw new IllegalStateException(message);
+ }
+
+ log.info("[STEP=auditStep] OK anchorDate={} groups={}", anchorDate, configs.size());
+ return RepeatStatus.FINISHED;
+ }
+
+ private List auditPeriod(LocalDate anchorDate, String periodType,
+ String weightGroup, String sql) {
+ AuditResult result = jdbcTemplate.queryForObject(sql,
+ (rs, rn) -> new AuditResult(
+ rs.getInt("row_count"),
+ rs.getInt("min_rank"),
+ rs.getInt("max_rank"),
+ rs.getInt("distinct_products")
+ ),
+ Date.valueOf(anchorDate), weightGroup);
+
+ // 불변조건: (1) count ≤ TOP_N, (2) rank 가 1..count 로 연속, (3) product_id 중복 없음.
+ // count 는 "정확히 100" 이 아니라 "TOP_N 이하 + 실제 상품 수만큼 채워짐" 이면 OK
+ // (테스트·초기 운영처럼 상품이 적은 환경도 정상 취급).
+ List problems = new ArrayList<>();
+ if (result.rowCount > EXPECTED_COUNT) {
+ problems.add(String.format(
+ "%s/%s count=%d exceeds TOP_N(%d)",
+ periodType, weightGroup, result.rowCount, EXPECTED_COUNT));
+ }
+ if (result.rowCount > 0) {
+ if (result.minRank != 1 || result.maxRank != result.rowCount) {
+ problems.add(String.format(
+ "%s/%s rank not contiguous: [%d,%d] for count=%d",
+ periodType, weightGroup, result.minRank, result.maxRank, result.rowCount));
+ }
+ if (result.distinctProducts != result.rowCount) {
+ problems.add(String.format(
+ "%s/%s duplicate product_id: distinct=%d count=%d",
+ periodType, weightGroup, result.distinctProducts, result.rowCount));
+ }
+ }
+
+ if (problems.isEmpty()) {
+ auditLogRepository.save(BatchAuditLog.ok(
+ jobExecutionId, anchorDate, periodType, weightGroup, result.rowCount));
+ } else {
+ auditLogRepository.save(BatchAuditLog.failed(
+ jobExecutionId, anchorDate, periodType, weightGroup,
+ result.rowCount, String.join(" ; ", problems)));
+ }
+ return problems;
+ }
+
+ private record AuditResult(int rowCount, int minRank, int maxRank, int distinctProducts) {}
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java
new file mode 100644
index 0000000000..5e3d914b1b
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java
@@ -0,0 +1,30 @@
+package com.loopers.batch.job.ranking.step.promote;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.PlatformTransactionManager;
+
+@Configuration
+@RequiredArgsConstructor
+public class PromoteTopToMvStepConfig {
+
+ public static final String STEP_NAME = "promoteTopToMvStep";
+
+ private final JobRepository jobRepository;
+ private final PlatformTransactionManager transactionManager;
+ private final StepMonitorListener stepMonitorListener;
+ private final PromoteTopToMvTasklet promoteTopToMvTasklet;
+
+ @Bean(STEP_NAME)
+ public Step promoteTopToMvStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(promoteTopToMvTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
new file mode 100644
index 0000000000..5649916526
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
@@ -0,0 +1,112 @@
+package com.loopers.batch.job.ranking.step.promote;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.sql.Date;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+/**
+ * Step 5b — 2차 스테이징(staging_ranking_scored) 에서 TOP 100 만 MV 에 INSERT.
+ *
+ * (period_type × weight_group) 조합 당 한 번의 단일 SQL:
+ * {@code INSERT INTO mv SELECT ... ROW_NUMBER() OVER (ORDER BY score DESC) ... LIMIT 100}.
+ * Step 4a/4b 가 사전 DELETE 했으므로 MV 는 "비어있음 → 확정된 TOP 100" 두 상태만 통과한다.
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class PromoteTopToMvTasklet implements Tasklet {
+
+ public static final int TOP_N = 100;
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ // 각 period_type 별로 MV 테이블 이름이 다름
+ private static final String SQL_LAST_7D = sqlFor("mv_product_rank_last_7d");
+ private static final String SQL_LAST_30D = sqlFor("mv_product_rank_last_30d");
+
+ private final JdbcTemplate jdbcTemplate;
+ private final WeightConfigRepository weightConfigRepository;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Override
+ @Transactional
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ List configs = weightConfigRepository.findAllByActiveTrue();
+ if (configs.isEmpty()) {
+ configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+
+ Timestamp createdAt = Timestamp.valueOf(LocalDateTime.now());
+ int totalInserted = 0;
+
+ for (WeightConfig config : configs) {
+ totalInserted += promote(SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D,
+ anchorDate, config.getGroupName(), createdAt);
+ totalInserted += promote(SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D,
+ anchorDate, config.getGroupName(), createdAt);
+ }
+
+ log.info("[STEP=promoteTopToMvStep] anchorDate={} groups={} inserted={}",
+ anchorDate, configs.size(), totalInserted);
+
+ contribution.incrementWriteCount(totalInserted);
+ return RepeatStatus.FINISHED;
+ }
+
+ private int promote(String sql, String periodType, LocalDate anchorDate,
+ String weightGroup, Timestamp createdAt) {
+ // SQL 의 ? 출현 순서: period_type, period_key, weight_group (CTE WHERE)
+ // anchor_date, created_at (SELECT literal)
+ // top_n (WHERE rn <= ?)
+ return jdbcTemplate.update(
+ sql,
+ periodType, anchorDateKey, weightGroup,
+ Date.valueOf(anchorDate), createdAt,
+ TOP_N
+ );
+ }
+
+ private static String sqlFor(String mvTable) {
+ return """
+ INSERT INTO %s
+ (anchor_date, weight_group, product_id,
+ view_count, like_count, sales_amount,
+ score, rank_position, created_at)
+ WITH ranked AS (
+ SELECT weight_group, product_id,
+ view_count, like_count, sales_amount, score,
+ ROW_NUMBER() OVER (ORDER BY score DESC, product_id ASC) AS rn
+ FROM staging_ranking_scored
+ WHERE period_type = ?
+ AND period_key = ?
+ AND weight_group = ?
+ )
+ SELECT ?, weight_group, product_id,
+ view_count, like_count, sales_amount, score, rn, ?
+ FROM ranked
+ WHERE rn <= ?
+ """.formatted(mvTable);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java
new file mode 100644
index 0000000000..5da7633898
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java
@@ -0,0 +1,30 @@
+package com.loopers.batch.job.ranking.step.redis;
+
+import com.loopers.batch.listener.StepMonitorListener;
+import lombok.RequiredArgsConstructor;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@RequiredArgsConstructor
+public class RedisRefreshStepConfig {
+
+ public static final String STEP_NAME = "redisRefreshStep";
+
+ private final JobRepository jobRepository;
+ private final StepMonitorListener stepMonitorListener;
+ private final RedisRefreshTasklet redisRefreshTasklet;
+
+ @Bean(STEP_NAME)
+ public Step redisRefreshStep() {
+ // Redis 조작은 트랜잭션 매니저가 필요하지 않으므로 Resourceless 사용
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(redisRefreshTasklet, new ResourcelessTransactionManager())
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
new file mode 100644
index 0000000000..8c8b47874e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
@@ -0,0 +1,115 @@
+package com.loopers.batch.job.ranking.step.redis;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.batch.core.StepContribution;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+import java.sql.Date;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Step 6 — MV 의 확정된 TOP 100 을 Redis ZSET identity cache 로 복제한다.
+ *
+ * Shadow key 에 ZADD 후 RENAME 으로 원자적 교체 → 조회 중 깜빡임 없음.
+ * Redis 는 MV 의 identity mirror (score·순서 동일) — 새 계산은 없다.
+ *
+ * Step 6 실패는 치명적이지 않음 — MV 자체는 영속되어 있고
+ * 조회 API 가 MV fallback 으로 동일 응답을 만들 수 있음.
+ */
+@Slf4j
+@Component
+@StepScope
+@RequiredArgsConstructor
+public class RedisRefreshTasklet implements Tasklet {
+
+ private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
+ private static final Duration TTL = Duration.ofDays(3);
+
+ private static final String SQL_LAST_7D = """
+ SELECT product_id, score
+ FROM mv_product_rank_last_7d
+ WHERE anchor_date = ? AND weight_group = ?
+ ORDER BY rank_position
+ """;
+
+ private static final String SQL_LAST_30D = """
+ SELECT product_id, score
+ FROM mv_product_rank_last_30d
+ WHERE anchor_date = ? AND weight_group = ?
+ ORDER BY rank_position
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+ private final WeightConfigRepository weightConfigRepository;
+ private final RedisTemplate redisTemplate;
+
+ @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
+ private String anchorDateKey;
+
+ @Override
+ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+ LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ List configs = weightConfigRepository.findAllByActiveTrue();
+ if (configs.isEmpty()) {
+ configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+
+ int totalAdded = 0;
+ for (WeightConfig config : configs) {
+ String group = config.getGroupName();
+ totalAdded += refreshZSet(
+ "ranking:last7d:" + anchorDateKey + ":" + group,
+ SQL_LAST_7D, Date.valueOf(anchorDate), group);
+ totalAdded += refreshZSet(
+ "ranking:last30d:" + anchorDateKey + ":" + group,
+ SQL_LAST_30D, Date.valueOf(anchorDate), group);
+ }
+
+ log.info("[STEP=redisRefreshStep] anchorDate={} groups={} zaddCount={}",
+ anchorDate, configs.size(), totalAdded);
+ return RepeatStatus.FINISHED;
+ }
+
+ private int refreshZSet(String key, String sql, Date anchorDate, String weightGroup) {
+ List rows = jdbcTemplate.query(sql,
+ (rs, rn) -> new ProductScore(rs.getLong("product_id"), rs.getDouble("score")),
+ anchorDate, weightGroup);
+
+ if (rows.isEmpty()) {
+ redisTemplate.delete(key);
+ return 0;
+ }
+
+ String shadowKey = key + ":rebuild";
+ redisTemplate.delete(shadowKey);
+
+ Set> tuples = new HashSet<>(rows.size());
+ for (ProductScore row : rows) {
+ tuples.add(ZSetOperations.TypedTuple.of(String.valueOf(row.productId()), row.score()));
+ }
+ redisTemplate.opsForZSet().add(shadowKey, tuples);
+ redisTemplate.rename(shadowKey, key);
+ redisTemplate.expire(key, TTL);
+
+ return rows.size();
+ }
+
+ private record ProductScore(long productId, double score) {}
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
new file mode 100644
index 0000000000..6880dda04a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
@@ -0,0 +1,93 @@
+package com.loopers.domain.ranking.audit;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * Step 7 (audit) 의 검증 결과 기록.
+ * BATCH_JOB_EXECUTION 이 "Job 이 COMPLETED 되었는가" 를 보장한다면,
+ * 이 테이블은 "결과 데이터 자체가 불변조건을 만족하는가" 의 비즈니스 감사 로그.
+ */
+@Entity
+@Table(
+ name = "batch_audit_log",
+ indexes = @Index(
+ name = "idx_audit_anchor",
+ columnList = "anchor_date, period_type, weight_group"
+ )
+)
+@Getter
+public class BatchAuditLog {
+
+ public static final String STATUS_OK = "OK";
+ public static final String STATUS_FAILED = "FAILED";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "job_execution_id", nullable = false)
+ private Long jobExecutionId;
+
+ @Column(name = "anchor_date", nullable = false)
+ private LocalDate anchorDate;
+
+ @Column(name = "period_type", length = 16, nullable = false)
+ private String periodType;
+
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Column(name = "status", length = 8, nullable = false)
+ private String status;
+
+ @Column(name = "row_count", nullable = false)
+ private int rowCount;
+
+ @Column(name = "reason", length = 255)
+ private String reason;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ protected BatchAuditLog() {
+ }
+
+ public static BatchAuditLog ok(Long jobExecutionId, LocalDate anchorDate,
+ String periodType, String weightGroup, int rowCount) {
+ BatchAuditLog log = new BatchAuditLog();
+ log.jobExecutionId = jobExecutionId;
+ log.anchorDate = anchorDate;
+ log.periodType = periodType;
+ log.weightGroup = weightGroup;
+ log.status = STATUS_OK;
+ log.rowCount = rowCount;
+ log.reason = null;
+ log.createdAt = LocalDateTime.now();
+ return log;
+ }
+
+ public static BatchAuditLog failed(Long jobExecutionId, LocalDate anchorDate,
+ String periodType, String weightGroup,
+ int rowCount, String reason) {
+ BatchAuditLog log = new BatchAuditLog();
+ log.jobExecutionId = jobExecutionId;
+ log.anchorDate = anchorDate;
+ log.periodType = periodType;
+ log.weightGroup = weightGroup;
+ log.status = STATUS_FAILED;
+ log.rowCount = rowCount;
+ log.reason = reason;
+ log.createdAt = LocalDateTime.now();
+ return log;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java
new file mode 100644
index 0000000000..fcb0e1ab0c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.domain.ranking.audit;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public interface BatchAuditLogRepository {
+
+ // Command
+ BatchAuditLog save(BatchAuditLog log);
+
+ // Query
+ List findByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java
new file mode 100644
index 0000000000..4d804a26d5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.audit.BatchAuditLog;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.time.LocalDate;
+import java.util.List;
+
+interface BatchAuditLogJpaRepository extends JpaRepository {
+
+ List findByAnchorDate(LocalDate anchorDate);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java
new file mode 100644
index 0000000000..96e733bceb
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java
@@ -0,0 +1,26 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.audit.BatchAuditLog;
+import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Repository
+@RequiredArgsConstructor
+public class BatchAuditLogRepositoryImpl implements BatchAuditLogRepository {
+
+ private final BatchAuditLogJpaRepository jpaRepository;
+
+ @Override
+ public BatchAuditLog save(BatchAuditLog log) {
+ return jpaRepository.save(log);
+ }
+
+ @Override
+ public List findByAnchorDate(LocalDate anchorDate) {
+ return jpaRepository.findByAnchorDate(anchorDate);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
new file mode 100644
index 0000000000..8da0521457
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
@@ -0,0 +1,211 @@
+package com.loopers.batch.job.ranking;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.audit.BatchAuditLog;
+import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
+import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
+import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import com.loopers.utils.RedisCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+/**
+ * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 6 까지의 통과 검증.
+ * 원천 3개 테이블에 시드 → Job 실행 → MV + audit + Redis ZSET 결과 검증.
+ */
+@SpringBootTest
+@SpringBatchTest
+@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class RollingRankingJobE2ETest {
+
+ private static final String ANCHOR_KEY = "20260414";
+ private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
+ private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0);
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private WeightConfigRepository weightConfigRepository;
+ @Autowired private MvProductRankLast7dRepository last7dRepository;
+ @Autowired private MvProductRankLast30dRepository last30dRepository;
+ @Autowired private BatchAuditLogRepository auditLogRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private RedisTemplate redisTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+ @Autowired private RedisCleanUp redisCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ redisCleanUp.truncateAll();
+ }
+
+ @DisplayName("원천 → MV → audit → Redis ZSET 까지 전체 파이프라인이 통과하고 identity cache 가 된다.")
+ @Test
+ void fullPipelineSucceedsAndProducesIdentityCache() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ // 3 product, 각각 다른 원천
+ saveView(1L, IN_7D, 100); saveLike(1L, IN_7D, 50); saveOrder(1L, IN_7D, 999);
+ saveView(2L, IN_7D, 50); saveLike(2L, IN_7D, 10); saveOrder(2L, IN_7D, 100);
+ saveView(3L, IN_7D, 10); saveLike(3L, IN_7D, 2); saveOrder(3L, IN_7D, 10);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ // MV 에 TOP N (여기선 3 상품) 적재 + rank 1,2,3 연속
+ // Redis ZSET 에 같은 score 순으로 적재
+ Set> zsetLast7d = redisTemplate.opsForZSet()
+ .reverseRangeWithScores("ranking:last7d:" + ANCHOR_KEY + ":control", 0, -1);
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
+ () -> assertThat(rankPositions("mv_product_rank_last_7d", "control")).containsExactly(1, 2, 3),
+ // audit 로그 2건 (LAST_7D + LAST_30D)
+ () -> assertThat(auditLogRepository.findByAnchorDate(ANCHOR))
+ .extracting(BatchAuditLog::getStatus)
+ .containsOnly(BatchAuditLog.STATUS_OK),
+ // Redis ZSET 에 동일 3 상품이 동일 score 로 들어감 (identity cache)
+ () -> assertThat(zsetLast7d).hasSize(3)
+ );
+ }
+
+ @DisplayName("여러 weight_group 이 활성화되면 MV·Redis 모두 그룹별로 독립 생성된다.")
+ @Test
+ void multipleWeightGroupsEachHaveOwnMvAndRedisKey() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
+ weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 500);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // control + experiment_a 두 그룹 × 1 상품 = 2 row per MV
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(2L),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":experiment_a")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":control")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":experiment_a")).isTrue()
+ );
+ }
+
+ @DisplayName("같은 anchorDate 로 두 번 돌려도 최종 결과가 동일하다 (배치 멱등성).")
+ @Test
+ void idempotentOnRerun() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ double firstScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
+
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ double secondScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
+
+ assertAll(
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(1L),
+ () -> assertThat(secondScore).isEqualTo(firstScore)
+ );
+ }
+
+ @DisplayName("원천이 비어 있어도 Job 은 성공한다 (빈 배치 day).")
+ @Test
+ void emptySourceSucceedsWithNoMv() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isFalse()
+ );
+ }
+
+ // -- helpers --
+
+ private void saveView(long productId, LocalDateTime bucketTime, long viewCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), viewCount);
+ }
+
+ private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), likeCount);
+ }
+
+ private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " +
+ "VALUES (?, ?, 1, 1, ?)",
+ productId, Timestamp.valueOf(bucketTime), salesAmount);
+ }
+
+ private List rankPositions(String mvTable, String group) {
+ return jdbcTemplate.queryForList(
+ "SELECT rank_position FROM " + mvTable +
+ " WHERE anchor_date = ? AND weight_group = ? ORDER BY rank_position",
+ Integer.class, java.sql.Date.valueOf(ANCHOR), group);
+ }
+
+ private double scoreOfMv(String mvTable, String anchorKey, String group, long productId) {
+ Double s = jdbcTemplate.queryForObject(
+ "SELECT score FROM " + mvTable +
+ " WHERE anchor_date = ? AND weight_group = ? AND product_id = ?",
+ Double.class, java.sql.Date.valueOf(LocalDate.parse(
+ anchorKey.substring(0, 4) + "-" + anchorKey.substring(4, 6) + "-" + anchorKey.substring(6))),
+ group, productId);
+ return s == null ? 0.0 : s;
+ }
+
+ private JobParameters paramsOf(String anchorDate) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ }
+}
From f018adebe0b3b673921f2da7b677c8b9e56b54c7 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 21:15:52 +0900
Subject: [PATCH 08/21] =?UTF-8?q?feat:=20commerce-api=20=EC=9D=98=20?=
=?UTF-8?q?=EB=9E=AD=ED=82=B9=20MV=20=EC=A1=B0=ED=9A=8C=20=EC=96=B4?=
=?UTF-8?q?=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
commerce-batch 가 확정한 롤링 7일/30일 MV 를 commerce-api 가 읽기 위한
스키마 미러 엔티티와 Query Repository.
- MvProductRankId (anchor_date, weight_group, product_id) 공통 PK
- MvProductRankLast7d / MvProductRankLast30d 읽기용 엔티티
(쓰기 소유자는 commerce-batch, commerce-api 는 조회만)
- MvRankEntry: (productId, score, rankPosition) 최소 투영 DTO
- MvRankingQueryRepository + JdbcTemplate 기반 Impl
JPA Entity 로 읽지 않는 이유: 영속성 컨텍스트 불필요 + 단순 투영이라
JDBC 가 명료함 (설계.md "Writer 쪽 JPA 함정" 과 같은 결)
Redis identity cache miss 시 이 Repository 가 fallback 경로로 진입한다.
다음 커밋에서 RankingService/KeyResolver 를 LAST_7D/LAST_30D 로 교체하며 사용.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../domain/ranking/mv/MvProductRankId.java | 39 ++++++++++
.../ranking/mv/MvProductRankLast30d.java | 72 +++++++++++++++++
.../ranking/mv/MvProductRankLast7d.java | 76 ++++++++++++++++++
.../domain/ranking/mv/MvRankEntry.java | 8 ++
.../ranking/mv/MvRankingQueryRepository.java | 20 +++++
.../ranking/MvRankingQueryRepositoryImpl.java | 78 +++++++++++++++++++
6 files changed, 293 insertions(+)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
new file mode 100644
index 0000000000..9498f99111
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java
@@ -0,0 +1,39 @@
+package com.loopers.domain.ranking.mv;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.util.Objects;
+
+/**
+ * mv_product_rank_last_7d / mv_product_rank_last_30d 공통 PK.
+ * commerce-batch 가 생성·적재하는 MV 를 commerce-api 가 읽기 위한 스키마 미러.
+ */
+public class MvProductRankId implements Serializable {
+
+ private LocalDate anchorDate;
+ private String weightGroup;
+ private Long productId;
+
+ public MvProductRankId() {
+ }
+
+ public MvProductRankId(LocalDate anchorDate, String weightGroup, Long productId) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof MvProductRankId that)) return false;
+ return Objects.equals(anchorDate, that.anchorDate)
+ && Objects.equals(weightGroup, that.weightGroup)
+ && Objects.equals(productId, that.productId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(anchorDate, weightGroup, productId);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
new file mode 100644
index 0000000000..5b45eadf28
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java
@@ -0,0 +1,72 @@
+package com.loopers.domain.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(
+ name = "mv_product_rank_last_30d",
+ indexes = @Index(
+ name = "idx_last_30d_rank",
+ columnList = "anchor_date, weight_group, rank_position"
+ )
+)
+@IdClass(MvProductRankId.class)
+@Getter
+public class MvProductRankLast30d {
+
+ @Id
+ @Column(name = "anchor_date", nullable = false)
+ private LocalDate anchorDate;
+
+ @Id
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ @Column(name = "score", nullable = false)
+ private double score;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rankPosition;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ protected MvProductRankLast30d() {
+ }
+
+ public MvProductRankLast30d(LocalDate anchorDate, String weightGroup, Long productId,
+ long viewCount, long likeCount, long salesAmount,
+ double score, int rankPosition, LocalDateTime createdAt) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ this.score = score;
+ this.rankPosition = rankPosition;
+ this.createdAt = createdAt;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
new file mode 100644
index 0000000000..375a3a24e0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java
@@ -0,0 +1,76 @@
+package com.loopers.domain.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.IdClass;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import lombok.Getter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 롤링 7일 랭킹 확정 MV 의 commerce-api 측 읽기 모델.
+ * commerce-batch 가 쓰기 소유자이며, 여기서는 조회만 한다.
+ */
+@Entity
+@Table(
+ name = "mv_product_rank_last_7d",
+ indexes = @Index(
+ name = "idx_last_7d_rank",
+ columnList = "anchor_date, weight_group, rank_position"
+ )
+)
+@IdClass(MvProductRankId.class)
+@Getter
+public class MvProductRankLast7d {
+
+ @Id
+ @Column(name = "anchor_date", nullable = false)
+ private LocalDate anchorDate;
+
+ @Id
+ @Column(name = "weight_group", length = 32, nullable = false)
+ private String weightGroup;
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "sales_amount", nullable = false)
+ private long salesAmount;
+
+ @Column(name = "score", nullable = false)
+ private double score;
+
+ @Column(name = "rank_position", nullable = false)
+ private int rankPosition;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ protected MvProductRankLast7d() {
+ }
+
+ public MvProductRankLast7d(LocalDate anchorDate, String weightGroup, Long productId,
+ long viewCount, long likeCount, long salesAmount,
+ double score, int rankPosition, LocalDateTime createdAt) {
+ this.anchorDate = anchorDate;
+ this.weightGroup = weightGroup;
+ this.productId = productId;
+ this.viewCount = viewCount;
+ this.likeCount = likeCount;
+ this.salesAmount = salesAmount;
+ this.score = score;
+ this.rankPosition = rankPosition;
+ this.createdAt = createdAt;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java
new file mode 100644
index 0000000000..89f8662b27
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java
@@ -0,0 +1,8 @@
+package com.loopers.domain.ranking.mv;
+
+/**
+ * MV 조회 결과의 최소 표현 — product_id + score + rank_position.
+ * 나머지 컬럼(view/like/sales) 은 API 응답에 필요하지 않으므로 투영하지 않는다.
+ */
+public record MvRankEntry(Long productId, double score, int rankPosition) {
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java
new file mode 100644
index 0000000000..353465632f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.domain.ranking.mv;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * LAST_7D / LAST_30D MV 에 대한 조회 전용 Repository.
+ * Redis identity cache miss 시 fallback 경로가 여기로 진입한다.
+ */
+public interface MvRankingQueryRepository {
+
+ // Query
+ List findLast7d(LocalDate anchorDate, String weightGroup, int offset, int limit);
+
+ List findLast30d(LocalDate anchorDate, String weightGroup, int offset, int limit);
+
+ long countLast7d(LocalDate anchorDate, String weightGroup);
+
+ long countLast30d(LocalDate anchorDate, String weightGroup);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java
new file mode 100644
index 0000000000..699a5bd40a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java
@@ -0,0 +1,78 @@
+package com.loopers.infrastructure.ranking;
+
+import com.loopers.domain.ranking.mv.MvRankEntry;
+import com.loopers.domain.ranking.mv.MvRankingQueryRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Date;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * JdbcTemplate 기반 MV 조회. JPA Entity 로 읽지 않는 이유:
+ * - 응답에는 product_id/score/rank_position 만 필요 (나머지 컬럼 투영 불필요)
+ * - 영속성 컨텍스트가 MV 를 관리할 가치 없음 (쓰기는 commerce-batch 가 소유)
+ * - 단순 투영 SELECT 이므로 JDBC 가 가장 명료함 (설계.md "Writer 쪽 JPA 함정" 과 같은 결)
+ */
+@Repository
+@RequiredArgsConstructor
+public class MvRankingQueryRepositoryImpl implements MvRankingQueryRepository {
+
+ private static final String SELECT_LAST_7D = """
+ SELECT product_id, score, rank_position
+ FROM mv_product_rank_last_7d
+ WHERE anchor_date = ? AND weight_group = ?
+ ORDER BY rank_position
+ LIMIT ? OFFSET ?
+ """;
+
+ private static final String SELECT_LAST_30D = """
+ SELECT product_id, score, rank_position
+ FROM mv_product_rank_last_30d
+ WHERE anchor_date = ? AND weight_group = ?
+ ORDER BY rank_position
+ LIMIT ? OFFSET ?
+ """;
+
+ private static final String COUNT_LAST_7D =
+ "SELECT COUNT(*) FROM mv_product_rank_last_7d WHERE anchor_date = ? AND weight_group = ?";
+
+ private static final String COUNT_LAST_30D =
+ "SELECT COUNT(*) FROM mv_product_rank_last_30d WHERE anchor_date = ? AND weight_group = ?";
+
+ private final JdbcTemplate jdbcTemplate;
+
+ @Override
+ public List findLast7d(LocalDate anchorDate, String weightGroup, int offset, int limit) {
+ return jdbcTemplate.query(SELECT_LAST_7D,
+ (rs, rn) -> new MvRankEntry(
+ rs.getLong("product_id"),
+ rs.getDouble("score"),
+ rs.getInt("rank_position")),
+ Date.valueOf(anchorDate), weightGroup, limit, offset);
+ }
+
+ @Override
+ public List findLast30d(LocalDate anchorDate, String weightGroup, int offset, int limit) {
+ return jdbcTemplate.query(SELECT_LAST_30D,
+ (rs, rn) -> new MvRankEntry(
+ rs.getLong("product_id"),
+ rs.getDouble("score"),
+ rs.getInt("rank_position")),
+ Date.valueOf(anchorDate), weightGroup, limit, offset);
+ }
+
+ @Override
+ public long countLast7d(LocalDate anchorDate, String weightGroup) {
+ Long c = jdbcTemplate.queryForObject(COUNT_LAST_7D, Long.class, Date.valueOf(anchorDate), weightGroup);
+ return c == null ? 0L : c;
+ }
+
+ @Override
+ public long countLast30d(LocalDate anchorDate, String weightGroup) {
+ Long c = jdbcTemplate.queryForObject(COUNT_LAST_30D, Long.class, Date.valueOf(anchorDate), weightGroup);
+ return c == null ? 0L : c;
+ }
+}
From 066ae51a85d4f21d77ceec2f5dfad9d581c34de2 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 21:47:56 +0900
Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20API=20=EB=A5=BC=20LAST=5F7D/LAST=5F30D=20=EB=A1=A4?=
=?UTF-8?q?=EB=A7=81=20=EC=9C=88=EB=8F=84=EC=9A=B0=EB=A1=9C=20=EA=B5=90?=
=?UTF-8?q?=EC=B2=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
배치가 생성한 롤링 7일/30일 MV 를 그대로 노출하도록 API 계약을 전환한다.
사용자 언어는 "주간/월간" 이지만 내부적 의미가 "오늘 제외 롤링 N일" 이므로
enum 이름을 LAST_7D / LAST_30D 로 명시화 (설계.md 프롤로그 + 멘토링 결론).
- RankingPeriod: REALTIME, DAILY, LAST_7D, LAST_30D (WEEKLY/MONTHLY 제거)
- RankingKeyResolver.resolve: 캘린더 키 삭제, anchor_date = date-1 로
ranking:last7d:{yyyyMMdd}:{group} / ranking:last30d:{yyyyMMdd}:{group}
배치가 쓰는 Redis 키와 동일 포맷
- RankingService.loadRankEntries: 이원화된 fallback
· REALTIME/DAILY → 기존 bucket 집계 재계산 (RankingFallbackAggregator)
· LAST_7D/LAST_30D → MvRankingQueryRepository 직접 조회 (확정된 TOP N 투영)
- totalCount 도 Redis miss 시 MV count 로 fallback
테스트:
- RankingKeyResolverTest: 롤링 키 생성, 월 경계 안전성, anchor 계산
- RankingServiceMvFallbackTest (5 Mockito): Redis miss → MV 경로,
Redis hit → MV 미호출, 빈 MV 처리, totalCount fallback
- RankingApiE2ETest: LAST_7D/LAST_30D 200 응답 + MV fallback 2 시나리오
(실제 MV 테이블에 시드 후 API 호출로 end-to-end 검증)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../ranking/RankingKeyResolver.java | 25 ++--
.../application/ranking/RankingService.java | 60 +++++++---
.../loopers/domain/ranking/RankingPeriod.java | 13 ++-
.../ranking/RankingKeyResolverTest.java | 44 ++++---
.../ranking/RankingServiceMvFallbackTest.java | 107 ++++++++++++++++++
.../api/ranking/RankingApiE2ETest.java | 84 +++++++++++++-
6 files changed, 287 insertions(+), 46 deletions(-)
create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java
index b41ee951ee..8e26ffd09e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java
@@ -4,16 +4,13 @@
import org.springframework.stereotype.Component;
import java.time.Clock;
-import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
-import java.time.temporal.WeekFields;
@Component
public class RankingKeyResolver {
private static final DateTimeFormatter DAILY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
- private static final DateTimeFormatter MONTHLY_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
private final Clock clock;
@@ -25,13 +22,9 @@ public String resolve(RankingPeriod period, LocalDate date, String groupName) {
String base = switch (period) {
case REALTIME -> "ranking:realtime";
case DAILY -> "ranking:daily:" + date.format(DAILY_FORMAT);
- case WEEKLY -> {
- WeekFields iso = WeekFields.ISO;
- int year = date.get(iso.weekBasedYear());
- int week = date.get(iso.weekOfWeekBasedYear());
- yield String.format("ranking:weekly:%d%02d", year, week);
- }
- case MONTHLY -> "ranking:monthly:" + date.format(MONTHLY_FORMAT);
+ // LAST_7D / LAST_30D 는 anchor = 어제 (오늘 제외 롤링 윈도우) 기반 키
+ case LAST_7D -> "ranking:last7d:" + anchorDateKey(date);
+ case LAST_30D -> "ranking:last30d:" + anchorDateKey(date);
};
return base + ":" + groupName;
}
@@ -39,4 +32,16 @@ public String resolve(RankingPeriod period, LocalDate date, String groupName) {
public String resolve(RankingPeriod period, LocalDate date) {
return resolve(period, date, "control");
}
+
+ /**
+ * 조회 기준일(오늘) 로부터 anchor_date (= 어제) 를 반환한다.
+ * 배치가 이 anchor 로 MV / Redis ZSET 을 만들므로 API 도 동일 키로 조회해야 한다.
+ */
+ public LocalDate anchorDateOf(LocalDate date) {
+ return date.minusDays(1);
+ }
+
+ private String anchorDateKey(LocalDate date) {
+ return anchorDateOf(date).format(DAILY_FORMAT);
+ }
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
index e9411bf8f3..14cdf99ac0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -2,14 +2,14 @@
import com.loopers.domain.ranking.RankEntry;
import com.loopers.domain.ranking.RankingPeriod;
+import com.loopers.domain.ranking.mv.MvRankEntry;
+import com.loopers.domain.ranking.mv.MvRankingQueryRepository;
import com.loopers.infrastructure.ranking.RankingRedisRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
-import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
-import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
@@ -21,15 +21,18 @@ public class RankingService {
private final RankingRedisRepository rankingRedisRepository;
private final RankingKeyResolver keyResolver;
private final RankingFallbackAggregator fallbackAggregator;
+ private final MvRankingQueryRepository mvRankingQueryRepository;
private final ExperimentGroupResolver experimentGroupResolver;
public RankingService(RankingRedisRepository rankingRedisRepository,
RankingKeyResolver keyResolver,
RankingFallbackAggregator fallbackAggregator,
+ MvRankingQueryRepository mvRankingQueryRepository,
ExperimentGroupResolver experimentGroupResolver) {
this.rankingRedisRepository = rankingRedisRepository;
this.keyResolver = keyResolver;
this.fallbackAggregator = fallbackAggregator;
+ this.mvRankingQueryRepository = mvRankingQueryRepository;
this.experimentGroupResolver = experimentGroupResolver;
}
@@ -47,11 +50,18 @@ public long getTotalCount(RankingPeriod period, LocalDate date, String group) {
String key = keyResolver.resolve(period, date, group);
try {
Long count = rankingRedisRepository.getTotalCount(key);
- return count == null ? 0 : count;
+ if (count != null && count > 0) {
+ return count;
+ }
} catch (Exception e) {
log.warn("Redis totalCount 조회 실패: {}", e.getMessage());
- return 0;
}
+ // Redis miss 또는 0일 때 MV 카운트 fallback (LAST_7D / LAST_30D 만 해당)
+ return switch (period) {
+ case LAST_7D -> mvRankingQueryRepository.countLast7d(keyResolver.anchorDateOf(date), group);
+ case LAST_30D -> mvRankingQueryRepository.countLast30d(keyResolver.anchorDateOf(date), group);
+ default -> 0;
+ };
}
public Integer getProductRank(Long productId, RankingPeriod period, LocalDate date) {
@@ -67,14 +77,37 @@ private List loadRankEntries(RankingPeriod period, LocalDate date, in
return fromRedis;
}
} catch (Exception e) {
- log.warn("Redis 랭킹 조회 실패, DB fallback. period={}, date={}, group={}: {}",
+ log.warn("Redis 랭킹 조회 실패, fallback. period={}, date={}, group={}: {}",
period, date, group, e.getMessage());
}
- return fallbackFromDb(period, date, page, size);
+ return switch (period) {
+ // 롤링 랭킹: MV 테이블 직접 조회 (확정된 TOP N 을 그대로 투영, bucket SUM 재계산 아님)
+ case LAST_7D, LAST_30D -> fallbackFromMv(period, date, page, size, group);
+ // 실시간/일간: 기존 bucket 집계 재계산 경로
+ case REALTIME, DAILY -> fallbackFromBucketAggregation(period, date, page, size);
+ };
+ }
+
+ private List fallbackFromMv(RankingPeriod period, LocalDate date, int page, int size, String group) {
+ try {
+ LocalDate anchorDate = keyResolver.anchorDateOf(date);
+ int offset = page * size;
+ List rows = switch (period) {
+ case LAST_7D -> mvRankingQueryRepository.findLast7d(anchorDate, group, offset, size);
+ case LAST_30D -> mvRankingQueryRepository.findLast30d(anchorDate, group, offset, size);
+ default -> List.of();
+ };
+ return rows.stream()
+ .map(r -> new RankEntry(r.productId(), r.score(), r.rankPosition()))
+ .toList();
+ } catch (Exception e) {
+ log.error("MV fallback 실패. period={}, date={}, group={}", period, date, group, e);
+ return List.of();
+ }
}
- private List fallbackFromDb(RankingPeriod period, LocalDate date, int page, int size) {
+ private List fallbackFromBucketAggregation(RankingPeriod period, LocalDate date, int page, int size) {
try {
LocalDateTime from = calculateFrom(period, date);
LocalDateTime to = RankingDateUtils.kstDateToUtcBoundary(date.plusDays(1));
@@ -94,7 +127,7 @@ private List fallbackFromDb(RankingPeriod period, LocalDate date, int
.map(e -> new RankEntry(e.getKey(), e.getValue(), rankCounter.getAndIncrement()))
.toList();
} catch (Exception e) {
- log.error("DB fallback도 실패. period={}, date={}", period, date, e);
+ log.error("bucket 집계 fallback 실패. period={}, date={}", period, date, e);
return List.of();
}
}
@@ -109,14 +142,9 @@ private LocalDateTime calculateFrom(RankingPeriod period, LocalDate date) {
java.time.ZoneOffset.UTC);
}
case DAILY -> RankingDateUtils.kstDateToUtcBoundary(date);
- case WEEKLY -> {
- LocalDate monday = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
- yield RankingDateUtils.kstDateToUtcBoundary(monday);
- }
- case MONTHLY -> {
- LocalDate firstOfMonth = date.withDayOfMonth(1);
- yield RankingDateUtils.kstDateToUtcBoundary(firstOfMonth);
- }
+ // LAST_7D / LAST_30D 는 MV fallback 경로라 여기 들어올 일 없음
+ case LAST_7D, LAST_30D -> throw new IllegalStateException(
+ "rolling period fallback 은 MV 경로로만 처리되어야 한다: " + period);
};
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
index 9cecf19598..1ed453d3a6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
@@ -1,5 +1,16 @@
package com.loopers.domain.ranking;
+/**
+ * 랭킹 조회 기간.
+ *
+ * WEEKLY/MONTHLY 캘린더 경계는 "월요일 오전/매월 1일 오전에 표본이 1일치" 라는 빈약성
+ * 문제가 있고, 실무에선 이커머스 랭킹을 롤링 N일 (오늘 제외) 로 구현하는 것이 일반적이다
+ * (설계.md 프롤로그 + 데빈/케브/앨런 멘토링 결론). 본 API 는 배치가 만드는 롤링 MV 와
+ * 일관되게 LAST_7D / LAST_30D 로 노출한다.
+ */
public enum RankingPeriod {
- REALTIME, DAILY, WEEKLY, MONTHLY
+ REALTIME,
+ DAILY,
+ LAST_7D,
+ LAST_30D
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java
index 08cf845a64..89469ce08c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java
@@ -48,33 +48,40 @@ class 일간_키 {
}
@Nested
- class 주간_키 {
+ class 롤링_7일_키 {
@Test
- void ISO_주차가_포함된_주간_키를_생성한다() {
- // 2026-04-10 금요일 → ISO week 15
- String key = resolver.resolve(RankingPeriod.WEEKLY, LocalDate.of(2026, 4, 10), "control");
+ void 어제_기준_anchor_로_last7d_키를_생성한다() {
+ // 조회 기준일 2026-04-15 → anchor_date = 2026-04-14 (오늘 제외)
+ String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "control");
- assertThat(key).isEqualTo("ranking:weekly:202615:control");
+ assertThat(key).isEqualTo("ranking:last7d:20260414:control");
}
@Test
- void 연초_주차가_올바르게_계산된다() {
- // 2026-01-01 목요일 → ISO week 1
- String key = resolver.resolve(RankingPeriod.WEEKLY, LocalDate.of(2026, 1, 1), "control");
+ void 실험_그룹별로_독립된_키를_생성한다() {
+ String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "experiment_a");
- assertThat(key).isEqualTo("ranking:weekly:202601:control");
+ assertThat(key).isEqualTo("ranking:last7d:20260414:experiment_a");
+ }
+
+ @Test
+ void 월_경계를_걸쳐도_음수_날짜없이_안전하게_계산된다() {
+ // 조회 기준일 2026-01-01 → anchor_date = 2025-12-31
+ String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 1, 1), "control");
+
+ assertThat(key).isEqualTo("ranking:last7d:20251231:control");
}
}
@Nested
- class 월간_키 {
+ class 롤링_30일_키 {
@Test
- void 연월이_포함된_월간_키를_생성한다() {
- String key = resolver.resolve(RankingPeriod.MONTHLY, LocalDate.of(2026, 4, 10), "control");
+ void 어제_기준_anchor_로_last30d_키를_생성한다() {
+ String key = resolver.resolve(RankingPeriod.LAST_30D, LocalDate.of(2026, 4, 15), "control");
- assertThat(key).isEqualTo("ranking:monthly:202604:control");
+ assertThat(key).isEqualTo("ranking:last30d:20260414:control");
}
}
@@ -88,4 +95,15 @@ class 기본_그룹 {
assertThat(key).isEqualTo("ranking:daily:20260410:control");
}
}
+
+ @Nested
+ class anchor_date_계산 {
+
+ @Test
+ void 오늘의_anchor_는_어제이다() {
+ LocalDate anchor = resolver.anchorDateOf(LocalDate.of(2026, 4, 15));
+
+ assertThat(anchor).isEqualTo(LocalDate.of(2026, 4, 14));
+ }
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
new file mode 100644
index 0000000000..b66431ff6f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
@@ -0,0 +1,107 @@
+package com.loopers.application.ranking;
+
+import com.loopers.domain.ranking.RankEntry;
+import com.loopers.domain.ranking.RankingPeriod;
+import com.loopers.domain.ranking.mv.MvRankEntry;
+import com.loopers.domain.ranking.mv.MvRankingQueryRepository;
+import com.loopers.infrastructure.ranking.RankingRedisRepository;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+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.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+/**
+ * Redis miss 시 LAST_7D / LAST_30D 는 MV 테이블로 fallback 한다.
+ */
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
+class RankingServiceMvFallbackTest {
+
+ private final RankingRedisRepository redisRepository = Mockito.mock(RankingRedisRepository.class);
+ private final RankingFallbackAggregator bucketFallback = Mockito.mock(RankingFallbackAggregator.class);
+ private final MvRankingQueryRepository mvRepository = Mockito.mock(MvRankingQueryRepository.class);
+ private final ExperimentGroupResolver groupResolver = Mockito.mock(ExperimentGroupResolver.class);
+ private final RankingKeyResolver keyResolver = new RankingKeyResolver(Clock.system(ZoneId.of("Asia/Seoul")));
+
+ private final RankingService service = new RankingService(
+ redisRepository, keyResolver, bucketFallback, mvRepository, groupResolver);
+
+ @Test
+ void Redis_miss_시_LAST_7D_는_MV_테이블에서_어제_anchor_로_조회한다() {
+ // given: Redis 빈 응답
+ when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of());
+ when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 14)), eq("control"), eq(0), eq(20)))
+ .thenReturn(List.of(
+ new MvRankEntry(1L, 99.9, 1),
+ new MvRankEntry(2L, 88.8, 2)
+ ));
+
+ // when: 조회 기준일 2026-04-15 → anchor_date = 2026-04-14
+ List result = service.getRankEntries(
+ RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control");
+
+ // then: MV 결과를 rank_position 순으로 투영
+ assertThat(result).extracting(RankEntry::productId).containsExactly(1L, 2L);
+ assertThat(result).extracting(RankEntry::rank).containsExactly(1, 2);
+ assertThat(result).extracting(RankEntry::score).containsExactly(99.9, 88.8);
+ Mockito.verify(bucketFallback, Mockito.never()).aggregate(any(), any());
+ }
+
+ @Test
+ void LAST_30D_도_동일한_MV_fallback_경로를_탄다() {
+ when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of());
+ when(mvRepository.findLast30d(eq(LocalDate.of(2026, 4, 14)), eq("control"), eq(0), eq(10)))
+ .thenReturn(List.of(new MvRankEntry(5L, 55.5, 1)));
+
+ List result = service.getRankEntries(
+ RankingPeriod.LAST_30D, LocalDate.of(2026, 4, 15), 0, 10, "control");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).productId()).isEqualTo(5L);
+ }
+
+ @Test
+ void Redis_가_응답하면_MV_는_호출되지_않는다() {
+ when(redisRepository.getRankings(anyString(), anyInt(), anyInt()))
+ .thenReturn(List.of(new RankEntry(7L, 42.0, 1)));
+
+ List result = service.getRankEntries(
+ RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).productId()).isEqualTo(7L);
+ Mockito.verifyNoInteractions(mvRepository);
+ }
+
+ @Test
+ void MV_도_비어있으면_빈_리스트를_반환한다() {
+ when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of());
+ when(mvRepository.findLast7d(any(), anyString(), anyInt(), anyInt())).thenReturn(List.of());
+
+ List result = service.getRankEntries(
+ RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control");
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void totalCount_도_Redis_miss_시_MV_카운트로_fallback_한다() {
+ when(redisRepository.getTotalCount(anyString())).thenReturn(0L);
+ when(mvRepository.countLast7d(eq(LocalDate.of(2026, 4, 14)), eq("control"))).thenReturn(100L);
+
+ long total = service.getTotalCount(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "control");
+
+ assertThat(total).isEqualTo(100L);
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java
index 723f7a7ef7..ed2e5240e1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java
@@ -14,6 +14,7 @@
import org.springframework.context.annotation.Import;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
@@ -47,6 +48,9 @@ class RankingApiE2ETest {
@Autowired
private RedisTemplate redisTemplate;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
@Autowired
private Clock clock;
@@ -201,28 +205,96 @@ class 기간별_조회 {
}
@Test
- void 주간_기간으로_조회하면_200_응답한다() {
- ResponseEntity> response = getRankings("?period=WEEKLY");
+ void 롤링_7일_기간으로_조회하면_200_응답한다() {
+ ResponseEntity> response = getRankings("?period=LAST_7D");
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
- () -> assertThat(response.getBody().data().period().name()).isEqualTo("WEEKLY")
+ () -> assertThat(response.getBody().data().period().name()).isEqualTo("LAST_7D")
);
}
@Test
- void 월간_기간으로_조회하면_200_응답한다() {
- ResponseEntity> response = getRankings("?period=MONTHLY");
+ void 롤링_30일_기간으로_조회하면_200_응답한다() {
+ ResponseEntity> response = getRankings("?period=LAST_30D");
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
- () -> assertThat(response.getBody().data().period().name()).isEqualTo("MONTHLY")
+ () -> assertThat(response.getBody().data().period().name()).isEqualTo("LAST_30D")
+ );
+ }
+ }
+
+ @Nested
+ class 롤링_랭킹_MV_fallback {
+
+ @Test
+ void LAST_7D_는_Redis_가_비어있고_MV_에만_데이터가_있으면_MV_에서_조회하여_응답한다() {
+ Long brandId = fixture.registerBrand("나이키", "스포츠");
+ Long productId1 = fixture.registerProduct(brandId, "상품A", BigDecimal.valueOf(10000), 100, "설명A");
+ Long productId2 = fixture.registerProduct(brandId, "상품B", BigDecimal.valueOf(20000), 100, "설명B");
+
+ // 조회 기준일 = 2026-04-15 → anchor_date = 2026-04-14
+ String targetDate = "20260415";
+ insertMvLast7d(java.sql.Date.valueOf("2026-04-14"), "control", productId1, 99.9, 1);
+ insertMvLast7d(java.sql.Date.valueOf("2026-04-14"), "control", productId2, 50.0, 2);
+ // Redis 는 비워둠 → MV fallback 경로
+
+ ResponseEntity> response =
+ getRankings("?period=LAST_7D&date=" + targetDate);
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody().data().items()).hasSize(2),
+ () -> assertThat(response.getBody().data().items().get(0).rank()).isEqualTo(1),
+ () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("상품A"),
+ () -> assertThat(response.getBody().data().items().get(0).score()).isEqualTo(99.9),
+ () -> assertThat(response.getBody().data().items().get(1).rank()).isEqualTo(2),
+ () -> assertThat(response.getBody().data().items().get(1).productName()).isEqualTo("상품B")
+ );
+ }
+
+ @Test
+ void LAST_30D_도_동일하게_MV_fallback_경로로_응답한다() {
+ Long brandId = fixture.registerBrand("나이키", "스포츠");
+ Long productId = fixture.registerProduct(brandId, "상품A", BigDecimal.valueOf(10000), 100, "설명A");
+
+ String targetDate = "20260415";
+ insertMvLast30d(java.sql.Date.valueOf("2026-04-14"), "control", productId, 77.7, 1);
+
+ ResponseEntity> response =
+ getRankings("?period=LAST_30D&date=" + targetDate);
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody().data().items()).hasSize(1),
+ () -> assertThat(response.getBody().data().items().get(0).score()).isEqualTo(77.7)
);
}
}
// --- 헬퍼 메서드 ---
+ private void insertMvLast7d(java.sql.Date anchorDate, String group, long productId, double score, int rank) {
+ jdbcTemplate.update(
+ "INSERT INTO mv_product_rank_last_7d " +
+ "(anchor_date, weight_group, product_id, view_count, like_count, sales_amount, " +
+ " score, rank_position, created_at) " +
+ "VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?)",
+ anchorDate, group, productId, score, rank,
+ java.sql.Timestamp.valueOf(java.time.LocalDateTime.of(2026, 4, 15, 1, 0)));
+ }
+
+ private void insertMvLast30d(java.sql.Date anchorDate, String group, long productId, double score, int rank) {
+ jdbcTemplate.update(
+ "INSERT INTO mv_product_rank_last_30d " +
+ "(anchor_date, weight_group, product_id, view_count, like_count, sales_amount, " +
+ " score, rank_position, created_at) " +
+ "VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?)",
+ anchorDate, group, productId, score, rank,
+ java.sql.Timestamp.valueOf(java.time.LocalDateTime.of(2026, 4, 15, 1, 0)));
+ }
+
private ResponseEntity> getRankings(String queryString) {
return testRestTemplate.exchange(
ENDPOINT + queryString,
From aef63c9a3aee0e3c1b37a1bd83610b290a7713cb Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 22:21:24 +0900
Subject: [PATCH 10/21] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EC=8B=9C=EB=82=98?=
=?UTF-8?q?=EB=A6=AC=EC=98=A4=204=EC=A2=85=20=EC=B6=94=EA=B0=80=20+=20chun?=
=?UTF-8?q?k=20Reader=20saveState=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
설계.md 의 재시작 검증 시나리오를 구현. Streaming aggregator 의 lookahead 와
Spring Batch chunk-mid restart 가 본질적으로 충돌하므로, Step 1~3/5 의
chunk Reader 를 saveState=false 로 두고 처음부터 재시작하게 한다.
대신 UPSERT 의 멱등성으로 "재실행해도 같은 결과" 를 보장 (이중 안전장치 중 2차).
- ViewMetricStreamingReader / LikeMetricStreamingReader /
OrderMetricStreamingReader / ScoreAggregationStep 의 cursor reader 에
.saveState(false) 적용
테스트 (RollingRankingJobRestartTest, 4 시나리오):
- Scenario 1: Step 1 chunk-mid 실패 → restart → 1200 product staging 결과
한 번에 돌렸을 때와 동일 (UPSERT 멱등성 검증)
- Scenario 2a: Step 5 (Score) 실패 → MV 비어있음 보장
(WeightConfigRepository SpyBean 으로 첫 호출 throw)
- Scenario 2b: Step 5b (Promote) 실패 → 2차 staging 적재된 채 MV 비어있음
(호출 카운트로 두 번째 호출만 throw → ScoreProcessor 통과, Promote 실패)
- Scenario 3: 다른 anchorDate (20260413, 20260414) 가 서로 격리되어
덮어쓰지 않음 (백필 안전성)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../score/ScoreAggregationStepConfig.java | 2 +
.../step/stage/LikeMetricStreamingReader.java | 1 +
.../stage/OrderMetricStreamingReader.java | 1 +
.../step/stage/ViewMetricStreamingReader.java | 4 +
.../ranking/RollingRankingJobRestartTest.java | 254 ++++++++++++++++++
5 files changed, 262 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
index a99a04f684..ccaa7a53b7 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
@@ -43,6 +43,8 @@ public JdbcCursorItemReader stagingAggregationCursorR
.name("stagingAggregationCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
+ // saveState=false: 2차 staging 도 UPSERT 라 멱등. restart 시 처음부터.
+ .saveState(false)
.sql("""
SELECT period_type, period_key, product_id,
view_count, like_count, sales_amount
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
index 7385d0b4fc..018397dcef 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
@@ -42,6 +42,7 @@ public LikeMetricStreamingReader(
.name("likeMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
+ .saveState(false) // ViewMetricStreamingReader 주석 참고
.sql("""
SELECT product_id, bucket_time, like_count
FROM product_like_metrics
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
index a886d25e04..c348510e9d 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
@@ -42,6 +42,7 @@ public OrderMetricStreamingReader(
.name("orderMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
+ .saveState(false) // ViewMetricStreamingReader 주석 참고
.sql("""
SELECT product_id, bucket_time, sales_amount
FROM product_order_metrics
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
index fdaacbdc99..297c5c0039 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
@@ -46,6 +46,10 @@ public ViewMetricStreamingReader(
.name("viewMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
+ // saveState=false: streaming aggregator 의 lookahead 와 chunk-mid restart 가
+ // 충돌하므로 cursor 위치를 ExecutionContext 에 저장하지 않는다.
+ // 재시작 시 처음부터 다시 read → UPSERT 의 멱등성으로 결정적 결과 보장.
+ .saveState(false)
.sql("""
SELECT product_id, bucket_time, view_count
FROM product_view_metrics
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
new file mode 100644
index 0000000000..65e6049a1e
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
@@ -0,0 +1,254 @@
+package com.loopers.batch.job.ranking;
+
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.stage.StagingViewMetricsWriter;
+import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
+import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
+import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository;
+import com.loopers.domain.ranking.staging.StagingRankingScoredRepository;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import com.loopers.utils.RedisCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobLauncher;
+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.boot.test.mock.mockito.SpyBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * 설계.md 의 재시작 검증 시나리오 1/2/3.
+ *
+ * 모든 시나리오는 같은 anchorDate + 같은 runTimestamp 의 JobParameters 로
+ * 두 번 launchJob 한다. Spring Batch 가 같은 JobInstance 의 직전 FAILED 를 감지해
+ * 재시작 처리한다.
+ *
+ * 실패 주입은 SpyBean 에 호출 카운트 기반 throw 를 doAnswer 로 설정.
+ * 1차 실행이 FAILED 로 끝난 뒤 Mockito.reset 으로 throw 를 풀고 2차 실행한다.
+ */
+@SpringBootTest
+@SpringBatchTest
+@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class RollingRankingJobRestartTest {
+
+ private static final String ANCHOR_KEY = "20260414";
+ private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
+ private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0);
+
+ @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private JobLauncher jobLauncher;
+
+ @Autowired private WeightConfigRepository weightConfigRepository;
+ @Autowired private StagingRankingAggregationRepository stagingAggregationRepository;
+ @Autowired private StagingRankingScoredRepository stagingScoredRepository;
+ @Autowired private MvProductRankLast7dRepository last7dRepository;
+ @Autowired private MvProductRankLast30dRepository last30dRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+ @Autowired private RedisCleanUp redisCleanUp;
+
+ @SpyBean private StagingViewMetricsWriter viewWriter;
+ // WeightConfigRepository 는 ScoreProcessor (@BeforeStep) 와 PromoteTopToMvTasklet,
+ // AuditTasklet, RedisRefreshTasklet 모두에서 호출됨.
+ // 호출 순서 기반으로 throw 를 주입해 Step 5 또는 Step 5b 의 실패를 시뮬레이션한다.
+ @SpyBean private WeightConfigRepository weightConfigRepoSpy;
+
+ @AfterEach
+ void tearDown() {
+ Mockito.reset(viewWriter, weightConfigRepoSpy);
+ databaseCleanUp.truncateAllTables();
+ redisCleanUp.truncateAll();
+ }
+
+ // ---------- Scenario 1 ----------
+
+ @DisplayName("Scenario 1: Step 1 chunk 중 실패 → restart → 한 번에 돌렸을 때와 staging 결과 동일")
+ @Test
+ void scenario1_step1_chunkFailure_restart_yieldsSameAggregation() throws Exception {
+ seedBaselineWeightConfig();
+ // 여러 chunk 로 쪼개지도록 충분한 product 수 시드 (chunk size 500, 여기선 1200 product → 3 chunk)
+ int totalProducts = 1200;
+ for (long pid = 1; pid <= totalProducts; pid++) {
+ saveView(pid, IN_7D, 10);
+ }
+
+ // 두 번째 chunk write 호출에서 throw → 1 chunk 만 commit 된 상태로 FAILED
+ AtomicInteger calls = new AtomicInteger(0);
+ Mockito.doAnswer(invocation -> {
+ if (calls.incrementAndGet() == 2) {
+ throw new RuntimeException("의도적 chunk-mid 실패");
+ }
+ return invocation.callRealMethod();
+ }).when(viewWriter).write(any());
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 1L);
+ JobExecution first = jobLauncher.run(job, params);
+ assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED);
+
+ // throw 해제 후 restart
+ Mockito.reset(viewWriter);
+ JobExecution second = jobLauncher.run(job, params);
+
+ // 한 번에 돌렸을 때의 기대값 = 1200 product × view_count 10 → 각 product 합 10
+ // staging_ranking_aggregation 에 (LAST_7D, LAST_30D) × 1200 = 2400 row 가 모두 view_count=10
+ assertAll(
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY))
+ .isEqualTo(totalProducts * 2L),
+ // 첫 번째 product 의 LAST_7D row 가 정확히 10 (UPSERT 멱등성으로 중복 가산 안 됨)
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(10L)
+ );
+ }
+
+ // ---------- Scenario 2a ----------
+
+ @DisplayName("Scenario 2a: Step 5 (Score) 가 실패하면 MV 는 비어있다 (Step 5b 가 안 도므로)")
+ @Test
+ void scenario2a_step5Failure_keepsMvEmpty() throws Exception {
+ seedBaselineWeightConfig();
+ for (long pid = 1; pid <= 5; pid++) {
+ saveView(pid, IN_7D, 10);
+ }
+
+ // WeightConfigRepository.findAllByActiveTrue 는 ScoreProcessor 의 @BeforeStep 에서
+ // Step 5 시작 시 가장 먼저 호출됨. 첫 호출에서 throw → Step 5 fail.
+ Mockito.doThrow(new RuntimeException("의도적 Step 5 실패"))
+ .when(weightConfigRepoSpy).findAllByActiveTrue();
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 2L);
+ JobExecution first = jobLauncher.run(job, params);
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
+ // Step 5 가 실패해도 MV 는 비어있음 (Step 4a/4b 가 비운 그대로, Step 5b 안 돔)
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ // 1차 staging 까지는 정상 적재됨 (Step 1~3 통과)
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L)
+ );
+ }
+
+ // ---------- Scenario 2b ----------
+
+ @DisplayName("Scenario 2b: Step 5b (Promote) 가 실패하면 2차 staging 은 적재된 채 MV 만 비어있다")
+ @Test
+ void scenario2b_step5bFailure_keepsScoredStaging_butMvEmpty() throws Exception {
+ seedBaselineWeightConfig();
+ for (long pid = 1; pid <= 5; pid++) {
+ saveView(pid, IN_7D, 10);
+ }
+
+ // findAllByActiveTrue 호출 순서:
+ // #1 ScoreProcessor.@BeforeStep (Step 5) → 통과
+ // #2 PromoteTopToMvTasklet.execute (Step 5b) → throw
+ AtomicInteger calls = new AtomicInteger(0);
+ Mockito.doAnswer(invocation -> {
+ if (calls.incrementAndGet() == 2) {
+ throw new RuntimeException("의도적 Step 5b 실패");
+ }
+ return invocation.callRealMethod();
+ }).when(weightConfigRepoSpy).findAllByActiveTrue();
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 3L);
+ JobExecution first = jobLauncher.run(job, params);
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
+ // Step 5 까지는 완주 → 2차 staging 에 (LAST_7D + LAST_30D) × 5 product = 10 row
+ () -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L),
+ // Step 5b 가 실패했으므로 MV 는 여전히 비어있음 (중간 상태 불가시성)
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero()
+ );
+ }
+
+ // ---------- Scenario 3 ----------
+
+ @DisplayName("Scenario 3: 다른 anchorDate 는 서로 격리되어 한쪽이 다른쪽을 덮어쓰지 않는다")
+ @Test
+ void scenario3_differentAnchorsAreIsolated() throws Exception {
+ seedBaselineWeightConfig();
+
+ // anchor 20260414 용 데이터 (last7d 범위 안)
+ saveView(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100);
+ // anchor 20260413 용 데이터 (last7d 범위 안)
+ saveView(2L, LocalDateTime.of(2026, 4, 9, 12, 0), 50);
+
+ // 1차: anchorDate=20260414
+ JobExecution exec1 = jobLauncher.run(job, paramsOf("20260414", 11L));
+ // 2차: anchorDate=20260413 (백필 시나리오)
+ JobExecution exec2 = jobLauncher.run(job, paramsOf("20260413", 12L));
+
+ LocalDate anchor14 = LocalDate.of(2026, 4, 14);
+ LocalDate anchor13 = LocalDate.of(2026, 4, 13);
+
+ assertAll(
+ () -> assertThat(exec1.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(exec2.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // 두 anchor 의 MV 가 모두 보존됨
+ () -> assertThat(last7dRepository.countByAnchorDate(anchor14)).isPositive(),
+ () -> assertThat(last7dRepository.countByAnchorDate(anchor13)).isPositive(),
+ // 두 anchor 의 staging 도 격리
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260414")).isPositive(),
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260413")).isPositive()
+ );
+ }
+
+ // ---------- helpers ----------
+
+ private void seedBaselineWeightConfig() {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+
+ private void saveView(long productId, LocalDateTime bucketTime, long viewCount) {
+ jdbcTemplate.update(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ productId, Timestamp.valueOf(bucketTime), viewCount);
+ }
+
+ private long viewCount(String periodType, String periodKey, long productId) {
+ Long v = jdbcTemplate.queryForObject(
+ "SELECT view_count FROM staging_ranking_aggregation " +
+ " WHERE period_type=? AND period_key=? AND product_id=?",
+ Long.class, periodType, periodKey, productId);
+ return v == null ? 0L : v;
+ }
+
+ /**
+ * 같은 (anchorDate, runTimestamp) 페어는 같은 JobInstance 를 만들어
+ * Spring Batch 가 직전 FAILED 를 자동 restart 처리한다.
+ */
+ private JobParameters paramsOf(String anchorDate, long runTimestamp) {
+ return new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
+ .addLong("runTimestamp", runTimestamp)
+ .toJobParameters();
+ }
+}
From d716afc9c7fdfcfcf94d4c9114fc2805a11fcd38 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 22:30:21 +0900
Subject: [PATCH 11/21] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=8B=9C=EB=93=9C=20=EC=83=9D=EC=84=B1=EA=B8=B0?=
=?UTF-8?q?=EC=99=80=20=EB=B6=84=ED=8F=AC=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
설계.md "트래픽 전제" 의 5-tier (Hot/Warm/Normal/Cold/Sleeping) 분포를
Zipf α=1.2 로 자동 시드하고 분포 invariant 를 검증한다.
- Tier enum, SeedSpec(record): S/M/L 단계별 totalProducts 프리셋
- BaselineSeeder: Zipf 분포로 product 별 일일 이벤트 생성 →
view/like/order 메트릭 테이블에 JDBC batch INSERT
· view : like : order = 10 : 1 : 0.1
· 이벤트 양 = floor(C / (rank+1)^1.2), C=2000
· 활동 30% / Sleeping 70% (이벤트 0)
· seed 고정으로 결정적 재현
· rewriteBatchedStatements 환경에서 batchUpdate 가 SUCCESS_NO_INFO 를
반환하므로 row 수는 chunk.size() 로 누적
- BaselineSeederIntegrationTest (4):
· Sleeping 70% 이벤트 0 (활동 product ≤ totalProducts × 30%)
· Hot tier (상위 0.1%) 가 전체 view 의 30% 이상 점유 (Zipf head 검증)
· 같은 seed 로 두 번 돌리면 row 수 동일 (결정성)
· view : like : order ≈ 10 : 1 : 0.1 비율 검증
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/fixture/BaselineSeeder.java | 156 ++++++++++++++++++
.../BaselineSeederIntegrationTest.java | 126 ++++++++++++++
.../batch/job/ranking/fixture/SeedSpec.java | 28 ++++
.../batch/job/ranking/fixture/Tier.java | 13 ++
4 files changed, 323 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java
new file mode 100644
index 0000000000..ed5f550395
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java
@@ -0,0 +1,156 @@
+package com.loopers.batch.job.ranking.fixture;
+
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+
+/**
+ * 시드 생성기 — Zipf α=1.2 분포로 product 별 일일 이벤트를 생성하고
+ * product_view_metrics / product_like_metrics / product_order_metrics 에 적재한다.
+ *
+ * 설계.md "트래픽 전제 — 시드 규모와 상품 분포" 의 5-tier 비율을 따른다:
+ * Hot 0.1% / Warm 1% / Normal 9% / Cold 20% / Sleeping 70%.
+ * 활동 비율 (Hot+Warm+Normal+Cold) = 30%.
+ *
+ * 이벤트 양 = floor(C / (rank+1)^1.2). C 는 Hot tier 의 일일 이벤트가 ~2000 이 되도록 보정.
+ * 시간대는 단순화하여 일중 균등 분포 (5분 bucket 288개 중 무작위).
+ *
+ * view : like : order = 10 : 1 : 0.1 비율로 동일 product 에 시드.
+ */
+public class BaselineSeeder {
+
+ private static final int BUCKETS_PER_DAY = 24 * 12; // 5분 bucket 288개
+ private static final long BUCKET_SECONDS = 300L;
+ private static final double ZIPF_ALPHA = 1.2;
+ private static final int BATCH_SIZE = 1000;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public BaselineSeeder(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ /** 시드 실행 → 적재된 view row 수 반환. */
+ public SeedReport seed(SeedSpec spec) {
+ Random rng = new Random(spec.seed());
+ LocalDate startDate = spec.anchorDate().minusDays(spec.historyDays() - 1L);
+
+ // 활동 상품 = totalProducts × 30%, Sleeping 70% 는 시드 안 함
+ int activeCount = (int) Math.round(spec.totalProducts() * 0.30);
+
+ // Zipf 정규화 상수 — Hot tier 첫 product 의 일일 이벤트가 약 2000 이 되도록 보정
+ double scaleC = 2000.0;
+
+ List viewRows = new ArrayList<>(); // (productId, bucketEpochSec, count)
+ List likeRows = new ArrayList<>();
+ List orderRows = new ArrayList<>();
+
+ for (int rank = 0; rank < activeCount; rank++) {
+ long productId = rank + 1L;
+ int dailyViews = Math.max(1, (int) (scaleC / Math.pow(rank + 1, ZIPF_ALPHA)));
+ int dailyLikes = Math.max(0, dailyViews / 10);
+ int dailyOrders = Math.max(0, dailyViews / 100);
+
+ for (int day = 0; day < spec.historyDays(); day++) {
+ LocalDate date = startDate.plusDays(day);
+ accumulate(viewRows, productId, date, dailyViews, rng);
+ accumulate(likeRows, productId, date, dailyLikes, rng);
+ accumulate(orderRows, productId, date, dailyOrders, rng);
+ }
+ }
+
+ int viewInserted = bulkInsertViews(viewRows);
+ int likeInserted = bulkInsertLikes(likeRows);
+ int orderInserted = bulkInsertOrders(orderRows);
+
+ return new SeedReport(
+ spec.totalProducts(), activeCount,
+ viewInserted, likeInserted, orderInserted);
+ }
+
+ /** 한 product 의 하루치 이벤트를 5분 bucket 에 무작위 분산. */
+ private void accumulate(List rows, long productId, LocalDate date, int dailyTotal, Random rng) {
+ if (dailyTotal <= 0) return;
+
+ int[] bucketCounts = new int[BUCKETS_PER_DAY];
+ for (int i = 0; i < dailyTotal; i++) {
+ bucketCounts[rng.nextInt(BUCKETS_PER_DAY)]++;
+ }
+
+ long midnightSec = date.atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC);
+ for (int b = 0; b < BUCKETS_PER_DAY; b++) {
+ if (bucketCounts[b] == 0) continue;
+ rows.add(new long[]{productId, midnightSec + (long) b * BUCKET_SECONDS, bucketCounts[b]});
+ }
+ }
+
+ private int bulkInsertViews(List rows) {
+ return bulkInsert(
+ "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)",
+ rows);
+ }
+
+ private int bulkInsertLikes(List rows) {
+ return bulkInsert(
+ "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)",
+ rows);
+ }
+
+ private int bulkInsertOrders(List rows) {
+ // sales_amount = count × 10000 (가상의 단가).
+ // rewriteBatchedStatements=true 가 활성된 MySQL 드라이버는 batchUpdate 결과로
+ // SUCCESS_NO_INFO(-2) 를 반환하므로 row 수를 chunk.size() 로 누적한다.
+ String sql = "INSERT INTO product_order_metrics " +
+ "(product_id, bucket_time, order_count, quantity, sales_amount) " +
+ "VALUES (?, ?, ?, ?, ?)";
+ int total = 0;
+ for (int start = 0; start < rows.size(); start += BATCH_SIZE) {
+ int end = Math.min(start + BATCH_SIZE, rows.size());
+ List chunk = rows.subList(start, end);
+ jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
+ @Override public void setValues(PreparedStatement ps, int i) throws SQLException {
+ long[] r = chunk.get(i);
+ ps.setLong(1, r[0]);
+ ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.ofEpochSecond(r[1], 0, java.time.ZoneOffset.UTC)));
+ ps.setInt(3, (int) r[2]);
+ ps.setLong(4, r[2]);
+ ps.setLong(5, r[2] * 10_000L);
+ }
+ @Override public int getBatchSize() { return chunk.size(); }
+ });
+ total += chunk.size();
+ }
+ return total;
+ }
+
+ private int bulkInsert(String sql, List rows) {
+ int total = 0;
+ for (int start = 0; start < rows.size(); start += BATCH_SIZE) {
+ int end = Math.min(start + BATCH_SIZE, rows.size());
+ List chunk = rows.subList(start, end);
+ jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
+ @Override public void setValues(PreparedStatement ps, int i) throws SQLException {
+ long[] r = chunk.get(i);
+ ps.setLong(1, r[0]);
+ ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.ofEpochSecond(r[1], 0, java.time.ZoneOffset.UTC)));
+ ps.setLong(3, r[2]);
+ }
+ @Override public int getBatchSize() { return chunk.size(); }
+ });
+ total += chunk.size();
+ }
+ return total;
+ }
+
+ public record SeedReport(int totalProducts, int activeProducts,
+ int viewRowsInserted, int likeRowsInserted, int orderRowsInserted) {
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
new file mode 100644
index 0000000000..c309573e70
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
@@ -0,0 +1,126 @@
+package com.loopers.batch.job.ranking.fixture;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+/**
+ * 시드 분포 검증 — 설계.md "트래픽 전제" 의 두 가지 핵심 invariant 를 자동 검증한다.
+ *
+ * - Sleeping 70% 는 이벤트가 0 (활동 product = 전체의 30% 이하)
+ * - Hot tier (상위 0.1%) 가 전체 이벤트의 30% 이상을 점유 (Zipf head)
+ *
+ * 정확한 비율 (40/40/18/2) 은 Zipf α=1.2 한계상 ±편차가 큰데, 본질만 검증한다.
+ */
+@SpringBootTest
+@Import(MySqlTestContainersConfig.class)
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class BaselineSeederIntegrationTest {
+
+ private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
+
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("시드 결과: Sleeping 70% 는 이벤트 0 → 활동 상품은 totalProducts × 30% 이하")
+ @Test
+ void sleepingTierProducesNoEvents() {
+ SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ BaselineSeeder.SeedReport report = seeder.seed(spec);
+
+ long activeProducts = jdbcTemplate.queryForObject(
+ "SELECT COUNT(DISTINCT product_id) FROM product_view_metrics", Long.class);
+ long maxProductId = jdbcTemplate.queryForObject(
+ "SELECT MAX(product_id) FROM product_view_metrics", Long.class);
+
+ assertAll(
+ () -> assertThat(report.viewRowsInserted()).isPositive(),
+ () -> assertThat(activeProducts).isLessThanOrEqualTo(spec.totalProducts() * 30L / 100),
+ // Sleeping 영역 (rank ≥ 3000 = product_id ≥ 3001) 의 row 가 0
+ () -> assertThat(maxProductId).isLessThanOrEqualTo(3000L)
+ );
+ }
+
+ @DisplayName("시드 결과: Hot tier (상위 0.1%) 가 전체 view 이벤트의 30% 이상을 점유한다 (Zipf head)")
+ @Test
+ void hotTierDominatesEventVolume() {
+ SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ seeder.seed(spec);
+
+ // Hot = 상위 0.1% = product_id 1~10
+ Long hotEvents = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics WHERE product_id <= 10",
+ Long.class);
+ Long totalEvents = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics",
+ Long.class);
+
+ double hotShare = hotEvents.doubleValue() / totalEvents;
+ assertThat(hotShare)
+ .as("Hot tier share = " + hotShare)
+ .isGreaterThanOrEqualTo(0.30);
+ }
+
+ @DisplayName("시드는 결정적이다 — 같은 seed 로 두 번 돌리면 row 수가 동일")
+ @Test
+ void seedIsDeterministic() {
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ BaselineSeeder.SeedReport first = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
+ databaseCleanUp.truncateAllTables();
+ BaselineSeeder.SeedReport second = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
+
+ assertAll(
+ () -> assertThat(second.viewRowsInserted()).isEqualTo(first.viewRowsInserted()),
+ () -> assertThat(second.likeRowsInserted()).isEqualTo(first.likeRowsInserted()),
+ () -> assertThat(second.orderRowsInserted()).isEqualTo(first.orderRowsInserted())
+ );
+ }
+
+ @DisplayName("view : like : order = 10 : 1 : 0.1 비율로 시드된다")
+ @Test
+ void seedRatiosBetweenMetrics() {
+ SeedSpec spec = new SeedSpec(1_000, ANCHOR, 7, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ seeder.seed(spec);
+
+ long viewSum = sumColumn("product_view_metrics", "view_count");
+ long likeSum = sumColumn("product_like_metrics", "like_count");
+ long orderSum = sumColumn("product_order_metrics", "quantity");
+
+ // view 와 like 비율이 대략 10:1 부근 (정수 절단으로 일부 손실 허용)
+ assertAll(
+ () -> assertThat(likeSum).isLessThan(viewSum),
+ () -> assertThat(orderSum).isLessThan(likeSum),
+ () -> assertThat((double) viewSum / likeSum).isBetween(8.0, 12.0)
+ );
+ }
+
+ private long sumColumn(String table, String column) {
+ Long s = jdbcTemplate.queryForObject("SELECT COALESCE(SUM(" + column + "), 0) FROM " + table, Long.class);
+ return s == null ? 0L : s;
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java
new file mode 100644
index 0000000000..ebceea62b5
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java
@@ -0,0 +1,28 @@
+package com.loopers.batch.job.ranking.fixture;
+
+import java.time.LocalDate;
+
+/**
+ * 시드 생성 파라미터.
+ * - {@code totalProducts}: 전체 상품 수 (이 중 70% 는 Sleeping = 이벤트 0)
+ * - {@code anchorDate}: anchor (= 어제). last30dStart = anchor - 29일
+ * - {@code historyDays}: 시드를 만들 일수 (롤링 30일 검증엔 30 이상)
+ * - {@code seed}: 결정적 재현을 위한 random seed
+ *
+ * Zipf α=1.2 로 활동 상품(Hot+Warm+Normal+Cold = 30%) 의 일일 이벤트 양을 분포시킨다.
+ * S/M/L 단계는 totalProducts 만 다르게 두어 선형성을 측정한다.
+ */
+public record SeedSpec(int totalProducts, LocalDate anchorDate, int historyDays, long seed) {
+
+ public static SeedSpec small(LocalDate anchorDate) {
+ return new SeedSpec(1_000, anchorDate, 30, 42L);
+ }
+
+ public static SeedSpec medium(LocalDate anchorDate) {
+ return new SeedSpec(5_000, anchorDate, 30, 42L);
+ }
+
+ public static SeedSpec large(LocalDate anchorDate) {
+ return new SeedSpec(20_000, anchorDate, 30, 42L);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java
new file mode 100644
index 0000000000..73471e1e3c
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java
@@ -0,0 +1,13 @@
+package com.loopers.batch.job.ranking.fixture;
+
+/**
+ * 시드 데이터의 상품 활동 계층.
+ * 설계.md "트래픽 전제" 의 5-tier 분포에 매핑된다.
+ */
+public enum Tier {
+ HOT, // 대박 상품 (소수가 전체 이벤트의 큰 비중을 차지)
+ WARM, // 잘 나가는 상품
+ NORMAL, // 꾸준 판매
+ COLD, // 롱테일
+ SLEEPING // 비활동 (이벤트 0)
+}
From b4072caed5556a736f4e2f83ba2448631f48f51d Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Wed, 15 Apr 2026 22:51:15 +0900
Subject: [PATCH 12/21] =?UTF-8?q?test:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=EC=84=A0=ED=98=95=EC=84=B1/=EC=8A=A4=ED=8C=8C?=
=?UTF-8?q?=EC=9D=B4=ED=81=AC=20=EC=B8=A1=EC=A0=95=20=EB=B2=A4=EC=B9=98?=
=?UTF-8?q?=EB=A7=88=ED=81=AC=EC=99=80=20=EC=8B=A4=ED=96=89=20=EC=8A=A4?=
=?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
설계.md Phase 5 (#26~29) 의 측정을 자동화한다.
- RollingRankingJobBenchmark (@Tag("benchmark")):
S(1k) / M(5k) / L(20k) / XL_SPIKE(100k) 4단계로 시드 → Job 실행 →
build/benchmark-results.txt 에 KV 라인 (label, products, seedRows,
jobMs, tps, mv7dCount) append. gradle test 의 stdout 캡처 우회.
- build.gradle.kts:
· 평소 ./gradlew test 는 excludeTags("benchmark") 로 측정 SKIP
· ./gradlew benchmarkTest 가 includeTags 로 측정 전용 실행
- scripts/measure-ranking-batch.sh:
benchmarkTest 호출 + 결과 파일 파싱 → 표 형식 stdout
결과 정리는 사람이 week10/측정결과.md 에 직접 기록 (자동 생성 아님)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
apps/commerce-batch/build.gradle.kts | 22 +++
.../RollingRankingJobBenchmark.java | 146 ++++++++++++++++++
scripts/measure-ranking-batch.sh | 54 +++++++
3 files changed, 222 insertions(+)
create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
create mode 100755 scripts/measure-ranking-batch.sh
diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts
index b22b6477cc..165ad8207f 100644
--- a/apps/commerce-batch/build.gradle.kts
+++ b/apps/commerce-batch/build.gradle.kts
@@ -19,3 +19,25 @@ dependencies {
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))
}
+
+// 평소 ./gradlew test 에서는 @Tag("benchmark") 테스트를 제외한다.
+// 측정용 실행은 -PrunBenchmark=true 또는 별도 task ":apps:commerce-batch:benchmarkTest" 사용.
+tasks.named("test") {
+ useJUnitPlatform {
+ excludeTags("benchmark")
+ }
+}
+
+tasks.register("benchmarkTest") {
+ description = "랭킹 배치 선형성/스파이크 측정 (오래 걸림)"
+ group = "verification"
+ useJUnitPlatform {
+ includeTags("benchmark")
+ }
+ testClassesDirs = sourceSets["test"].output.classesDirs
+ classpath = sourceSets["test"].runtimeClasspath
+ // 측정 결과가 stdout 으로 흘러나오도록 standard output 강제 노출
+ testLogging {
+ showStandardStreams = true
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
new file mode 100644
index 0000000000..a88e8e65b4
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
@@ -0,0 +1,146 @@
+package com.loopers.batch.job.ranking.measurement;
+
+import com.loopers.batch.job.ranking.RollingRankingJobConfig;
+import com.loopers.batch.job.ranking.fixture.BaselineSeeder;
+import com.loopers.batch.job.ranking.fixture.SeedSpec;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+import com.loopers.utils.DatabaseCleanUp;
+import com.loopers.utils.RedisCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobLauncher;
+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.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * 선형성 / 스파이크 측정 벤치마크.
+ *
+ * 설계.md Phase 5 (#26~29) 의 "선형성 검증" + "스파이크 SLA" 측정.
+ * 각 테스트는 시드 → Job 실행 → 결과를 stdout 으로 출력하여 shell script 가 파싱한다.
+ *
+ * 평소 빌드에서는 {@code @Tag("benchmark")} 로 skip 되며, 명시적 명령으로만 실행:
+ * {@code ./gradlew :apps:commerce-batch:test --tests "...Benchmark" -PrunBenchmark=true}
+ */
+@Tag("benchmark")
+@SpringBootTest
+@SpringBatchTest
+@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
+@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+class RollingRankingJobBenchmark {
+
+ private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
+ private static final String ANCHOR_KEY = "20260414";
+
+ @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
+ @Autowired private JobLauncher jobLauncher;
+ @Autowired private WeightConfigRepository weightConfigRepository;
+ @Autowired private JdbcTemplate jdbcTemplate;
+ @Autowired private DatabaseCleanUp databaseCleanUp;
+ @Autowired private RedisCleanUp redisCleanUp;
+
+ @BeforeEach
+ void setUp() {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ redisCleanUp.truncateAll();
+ }
+
+ @DisplayName("[Benchmark] S단계 (1,000 product) 실행 시간")
+ @Test
+ void benchmark_small() throws Exception {
+ runBenchmark("S", SeedSpec.small(ANCHOR));
+ }
+
+ @DisplayName("[Benchmark] M단계 (5,000 product) 실행 시간")
+ @Test
+ void benchmark_medium() throws Exception {
+ runBenchmark("M", SeedSpec.medium(ANCHOR));
+ }
+
+ @DisplayName("[Benchmark] L단계 (20,000 product) 실행 시간")
+ @Test
+ void benchmark_large() throws Exception {
+ runBenchmark("L", SeedSpec.large(ANCHOR));
+ }
+
+ @DisplayName("[Benchmark] XL단계 - 스파이크 시뮬레이션 (활동 product 5배)")
+ @Test
+ void benchmark_xl_spike() throws Exception {
+ // L 의 5배 — Hot/Warm 의 일일 이벤트가 그만큼 폭증한 worst-case
+ SeedSpec spike = new SeedSpec(100_000, ANCHOR, 30, 42L);
+ runBenchmark("XL_SPIKE", spike);
+ }
+
+ private void runBenchmark(String label, SeedSpec spec) throws Exception {
+ // 1) 시드
+ Instant seedStart = Instant.now();
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+ BaselineSeeder.SeedReport seedReport = seeder.seed(spec);
+ Duration seedDuration = Duration.between(seedStart, Instant.now());
+
+ // 2) Job 실행
+ Instant jobStart = Instant.now();
+ JobParameters params = new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, ANCHOR_KEY)
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters();
+ JobExecution execution = jobLauncher.run(job, params);
+ Duration jobDuration = Duration.between(jobStart, Instant.now());
+
+ // 3) 결과를 파일에 append. (gradle test 가 stdout 을 캡처해 보이지 않으므로)
+ long totalRows = (long) seedReport.viewRowsInserted()
+ + seedReport.likeRowsInserted()
+ + seedReport.orderRowsInserted();
+ long mvCount = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM mv_product_rank_last_7d", Long.class);
+ double tps = jobDuration.toMillis() == 0 ? 0
+ : (double) totalRows / (jobDuration.toMillis() / 1000.0);
+
+ String line = "BENCH| label=" + label
+ + " status=" + execution.getStatus()
+ + " totalProducts=" + spec.totalProducts()
+ + " activeProducts=" + seedReport.activeProducts()
+ + " seedRows=" + totalRows
+ + " seedMs=" + seedDuration.toMillis()
+ + " jobMs=" + jobDuration.toMillis()
+ + " tpsRowsPerSec=" + String.format("%.1f", tps)
+ + " mv7dCount=" + mvCount + "\n";
+
+ // gradle test 의 working dir 는 :apps:commerce-batch 모듈 root 이므로 build/... 상대경로 사용
+ java.nio.file.Path outPath = java.nio.file.Paths.get(
+ System.getProperty("benchmark.outputFile", "build/benchmark-results.txt"));
+ java.nio.file.Files.createDirectories(outPath.toAbsolutePath().getParent());
+ java.nio.file.Files.writeString(outPath, line,
+ java.nio.file.StandardOpenOption.CREATE,
+ java.nio.file.StandardOpenOption.APPEND);
+
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ }
+}
diff --git a/scripts/measure-ranking-batch.sh b/scripts/measure-ranking-batch.sh
new file mode 100755
index 0000000000..9d7a795ce3
--- /dev/null
+++ b/scripts/measure-ranking-batch.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+#
+# 랭킹 배치 선형성·스파이크 측정 스크립트.
+#
+# 설계.md Phase 5 (#26~29) 의 측정을 자동화한다:
+# - S/M/L 단계별 실행 시간 → 선형성 검증 (입력 N배 → 시간 N배)
+# - XL_SPIKE 단계 → worst-case SLA 확인
+#
+# 사용법:
+# ./scripts/measure-ranking-batch.sh
+#
+# benchmark 테스트는 결과를 apps/commerce-batch/build/benchmark-results.txt 에 append.
+# 본 스크립트는 그 파일을 읽어 표 형식으로 출력한다.
+# 결과 정리는 사람이 week10/측정결과.md 에 직접 기록한다 (자동 생성 아님).
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+# gradle test 의 working dir 는 모듈 root 라 결과 파일은 모듈 build/ 아래에 생성됨
+OUT_FILE="apps/commerce-batch/build/benchmark-results.txt"
+rm -f "$OUT_FILE"
+
+echo "▶ benchmarkTest 실행 중 (수 분 ~ 수십 분 소요 가능)..."
+./gradlew :apps:commerce-batch:benchmarkTest --console=plain --rerun-tasks
+
+if [[ ! -f "$OUT_FILE" ]]; then
+ echo "❌ 결과 파일이 생성되지 않았습니다: $OUT_FILE"
+ exit 1
+fi
+
+echo
+echo "==================================================="
+echo " 측정 결과 요약 (raw: $OUT_FILE)"
+echo "==================================================="
+printf "%-10s %-10s %-10s %-10s %-10s %-10s %-12s\n" \
+ "label" "products" "active" "seedRows" "seedMs" "jobMs" "tps(rows/s)"
+echo "---------------------------------------------------"
+
+while read -r line; do
+ [[ "$line" =~ ^BENCH\| ]] || continue
+ label=$(echo "$line" | sed -n 's/.*label=\([^ ]*\).*/\1/p')
+ products=$(echo "$line" | sed -n 's/.*totalProducts=\([^ ]*\).*/\1/p')
+ active=$(echo "$line" | sed -n 's/.*activeProducts=\([^ ]*\).*/\1/p')
+ seedRows=$(echo "$line" | sed -n 's/.*seedRows=\([^ ]*\).*/\1/p')
+ seedMs=$(echo "$line" | sed -n 's/.*seedMs=\([^ ]*\).*/\1/p')
+ jobMs=$(echo "$line" | sed -n 's/.*jobMs=\([^ ]*\).*/\1/p')
+ tps=$(echo "$line" | sed -n 's/.*tpsRowsPerSec=\([^ ]*\).*/\1/p')
+ printf "%-10s %-10s %-10s %-10s %-10s %-10s %-12s\n" \
+ "$label" "$products" "$active" "$seedRows" "$seedMs" "$jobMs" "$tps"
+done < "$OUT_FILE"
+
+echo
+echo "▶ 결과를 week10/측정결과.md 에 정리해 기록하세요."
From d4406096aedc272213604b0a4785b23c3e0eacef Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 02:04:45 +0900
Subject: [PATCH 13/21] =?UTF-8?q?fix:=20streaming=20aggregator=20lookahead?=
=?UTF-8?q?=20=EB=A5=BC=20ExecutionContext=20=EC=97=90=20=EC=A7=81?=
=?UTF-8?q?=EB=A0=AC=ED=99=94=ED=95=98=EC=97=AC=20chunk-mid=20restart=20?=
=?UTF-8?q?=EB=B3=B5=EC=9B=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
saveState(false) + 전체 재시작 우회를 걷어내고, Spring Batch ItemStream 정석
패턴으로 교체한다. chunk commit 시 aggregator 의 lookahead (다음 product 의
첫 row) 를 primitive 3개 (productId, bucketTime, count) 로 ExecutionContext 에
저장하고, restart 시 open() 에서 복원한다.
이로써:
- 실패한 chunk 다음부터 정확하게 이어가기 가능 (누락 없음)
- saveState=true (기본값) 복원 → cursor 위치도 정상 저장
- UPSERT 멱등성은 여전히 이중 안전장치로 유지
변경 파일:
- StreamingMetricAggregator: getLookahead() / setLookahead() 추가
- ViewMetricStreamingReader: open/update 에 lookahead 직렬화/복원
+ saveState(false) 제거
- LikeMetricStreamingReader: 동일 패턴
- OrderMetricStreamingReader: 동일 패턴
- ScoreAggregationStepConfig: saveState(false) 제거 (Step 5 는 streaming
aggregation 없이 1:1 변환이므로 기본 saveState=true 가 정상 작동)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../score/ScoreAggregationStepConfig.java | 4 +--
.../step/stage/LikeMetricStreamingReader.java | 22 ++++++++++--
.../stage/OrderMetricStreamingReader.java | 22 ++++++++++--
.../step/stage/StreamingMetricAggregator.java | 10 ++++++
.../step/stage/ViewMetricStreamingReader.java | 36 ++++++++++++++++---
5 files changed, 83 insertions(+), 11 deletions(-)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
index ccaa7a53b7..9a28fbf632 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
@@ -43,8 +43,8 @@ public JdbcCursorItemReader stagingAggregationCursorR
.name("stagingAggregationCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
- // saveState=false: 2차 staging 도 UPSERT 라 멱등. restart 시 처음부터.
- .saveState(false)
+ // Step 5 는 1:1 row 변환 (streaming aggregation 없음) 이므로
+ // saveState=true (기본값) 로 chunk-mid restart 가 정상 작동
.sql("""
SELECT period_type, period_key, product_id,
view_count, like_count, sales_amount
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
index 018397dcef..f2030cefaa 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java
@@ -16,13 +16,16 @@
/**
* product_like_metrics 를 cursor 로 스트리밍 + App 집계.
- * {@link ViewMetricStreamingReader} 와 동일한 패턴이며 테이블·컬럼만 다르다.
+ * {@link ViewMetricStreamingReader} 와 동일한 패턴 (lookahead 직렬화 포함).
*/
@Component
@StepScope
public class LikeMetricStreamingReader implements ItemStreamReader {
private static final int FETCH_SIZE = 2000;
+ private static final String CTX_LOOKAHEAD_PRODUCT_ID = "lookahead.productId";
+ private static final String CTX_LOOKAHEAD_BUCKET_TIME = "lookahead.bucketTime";
+ private static final String CTX_LOOKAHEAD_COUNT = "lookahead.count";
private final JdbcCursorItemReader delegate;
private final LocalDateTime last7dStart;
@@ -42,7 +45,6 @@ public LikeMetricStreamingReader(
.name("likeMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
- .saveState(false) // ViewMetricStreamingReader 주석 참고
.sql("""
SELECT product_id, bucket_time, like_count
FROM product_like_metrics
@@ -66,11 +68,27 @@ public LikeMetricStreamingReader(
public void open(ExecutionContext executionContext) throws ItemStreamException {
delegate.open(executionContext);
this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+ if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) {
+ aggregator.setLookahead(new RawMetricRow(
+ executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID),
+ LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)),
+ executionContext.getLong(CTX_LOOKAHEAD_COUNT)));
+ }
}
@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
delegate.update(executionContext);
+ RawMetricRow lookahead = aggregator.getLookahead();
+ if (lookahead != null) {
+ executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId());
+ executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString());
+ executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count());
+ } else {
+ executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID);
+ executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME);
+ executionContext.remove(CTX_LOOKAHEAD_COUNT);
+ }
}
@Override
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
index c348510e9d..094da7c9a7 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java
@@ -16,13 +16,16 @@
/**
* product_order_metrics 의 sales_amount 를 cursor 로 스트리밍 + App 집계.
- * 랭킹 스코어에 쓰이는 것은 salesAmount 이므로 여기서는 그 컬럼만 집계 대상으로 삼는다.
+ * {@link ViewMetricStreamingReader} 와 동일한 패턴 (lookahead 직렬화 포함).
*/
@Component
@StepScope
public class OrderMetricStreamingReader implements ItemStreamReader {
private static final int FETCH_SIZE = 2000;
+ private static final String CTX_LOOKAHEAD_PRODUCT_ID = "lookahead.productId";
+ private static final String CTX_LOOKAHEAD_BUCKET_TIME = "lookahead.bucketTime";
+ private static final String CTX_LOOKAHEAD_COUNT = "lookahead.count";
private final JdbcCursorItemReader delegate;
private final LocalDateTime last7dStart;
@@ -42,7 +45,6 @@ public OrderMetricStreamingReader(
.name("orderMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
- .saveState(false) // ViewMetricStreamingReader 주석 참고
.sql("""
SELECT product_id, bucket_time, sales_amount
FROM product_order_metrics
@@ -66,11 +68,27 @@ public OrderMetricStreamingReader(
public void open(ExecutionContext executionContext) throws ItemStreamException {
delegate.open(executionContext);
this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+ if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) {
+ aggregator.setLookahead(new RawMetricRow(
+ executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID),
+ LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)),
+ executionContext.getLong(CTX_LOOKAHEAD_COUNT)));
+ }
}
@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
delegate.update(executionContext);
+ RawMetricRow lookahead = aggregator.getLookahead();
+ if (lookahead != null) {
+ executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId());
+ executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString());
+ executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count());
+ } else {
+ executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID);
+ executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME);
+ executionContext.remove(CTX_LOOKAHEAD_COUNT);
+ }
}
@Override
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
index 862672e4fa..93606ded1b 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java
@@ -64,4 +64,14 @@ public AggregatedMetric next() throws Exception {
}
return new AggregatedMetric(productId, sum7d, sum30d);
}
+
+ /** restart 시 ExecutionContext 직렬화/복원을 위한 lookahead 접근자. */
+ public RawMetricRow getLookahead() {
+ return lookahead;
+ }
+
+ /** restart 시 ExecutionContext 에서 복원한 lookahead 를 주입한다. */
+ public void setLookahead(RawMetricRow lookahead) {
+ this.lookahead = lookahead;
+ }
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
index 297c5c0039..7c66f3057e 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java
@@ -19,7 +19,11 @@
* product_view_metrics 를 (product_id, bucket_time) 순 cursor 로 스트리밍 읽고,
* App 측 StreamingMetricAggregator 로 product 경계마다 AggregatedMetric 을 흘려보낸다.
*
- * DB 는 단순 range scan 만 수행한다 (GROUP BY 없음). 집계는 App 책임.
+ * DB 는 단순 range scan 만 수행한다 (GROUP BY 없음). 집계는 App 책임.
+ *
+ * streaming aggregator 의 lookahead (다음 product 의 첫 row) 를
+ * ExecutionContext 에 직렬화하여 chunk-mid restart 시에도 누락 없이 이어갈 수 있다.
+ * 이는 Spring Batch 의 ItemStream 정석 패턴이다 (Common Batch Patterns 참고).
*/
@Slf4j
@Component
@@ -28,6 +32,10 @@ public class ViewMetricStreamingReader implements ItemStreamReader delegate;
private final LocalDateTime last7dStart;
private StreamingMetricAggregator aggregator;
@@ -46,10 +54,6 @@ public ViewMetricStreamingReader(
.name("viewMetricCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
- // saveState=false: streaming aggregator 의 lookahead 와 chunk-mid restart 가
- // 충돌하므로 cursor 위치를 ExecutionContext 에 저장하지 않는다.
- // 재시작 시 처음부터 다시 read → UPSERT 의 멱등성으로 결정적 결과 보장.
- .saveState(false)
.sql("""
SELECT product_id, bucket_time, view_count
FROM product_view_metrics
@@ -73,11 +77,33 @@ public ViewMetricStreamingReader(
public void open(ExecutionContext executionContext) throws ItemStreamException {
delegate.open(executionContext);
this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart);
+
+ // restart 시 이전 chunk 종료 시점의 lookahead 복원
+ if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) {
+ RawMetricRow restored = new RawMetricRow(
+ executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID),
+ LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)),
+ executionContext.getLong(CTX_LOOKAHEAD_COUNT)
+ );
+ aggregator.setLookahead(restored);
+ }
}
@Override
public void update(ExecutionContext executionContext) throws ItemStreamException {
delegate.update(executionContext);
+
+ // chunk commit 시점에 lookahead 를 primitive 3개로 직렬화
+ RawMetricRow lookahead = aggregator.getLookahead();
+ if (lookahead != null) {
+ executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId());
+ executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString());
+ executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count());
+ } else {
+ executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID);
+ executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME);
+ executionContext.remove(CTX_LOOKAHEAD_COUNT);
+ }
}
@Override
From cfe03d167de5a21959d393afc02cf678f0c6da64 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 08:38:26 +0900
Subject: [PATCH 14/21] =?UTF-8?q?test:=20Scenario=204=20-=20Hot=20product?=
=?UTF-8?q?=20=EA=B8=B4=20bucket=20=EC=B2=B4=EC=9D=B8=EC=9D=98=20chunk=20?=
=?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=AC=B4=EC=A0=88=EB=8B=A8=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
chunk size(500) 가 raw row 수가 아니라 product 수 기준이므로,
aggregator.next() 가 한 product 의 모든 bucket 을 원자적으로 소비한 후
emit 한다. 따라서 상품 중간 절단은 구조적으로 불가능하다.
이 올바름을 실증하는 Scenario 4 추가:
- product 1: bucket 1,000개 (count=1,2,...,1000)
- product 2, 3: bucket 5개씩 (count=10)
- chunk size=500 이지만 3 product 라 chunk 1 에서 전부 처리
- 검증: product 1 의 staging view_count = 500,500 (1+2+...+1000)
중간 절단 시 이 합계가 달라지므로 정확성 완전 입증
- 검증: 총 staging = 3 product × 2 period = 6 row
이로써 CompletionPolicy 불필요 확인 — emit 단위가 이미 product 경계.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../ranking/RollingRankingJobRestartTest.java | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
index 65e6049a1e..dfe161a923 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
@@ -221,6 +221,45 @@ void scenario3_differentAnchorsAreIsolated() throws Exception {
);
}
+ // ---------- Scenario 4 ----------
+
+ @DisplayName("Scenario 4: Hot product 가 bucket 1,000개를 소유해도 상품 중간 절단 없이 정확히 집계된다")
+ @Test
+ void scenario4_hotProductLongChain_noMidProductTruncation() throws Exception {
+ seedBaselineWeightConfig();
+
+ // product 1: bucket 1,000개 (chunk size=500 보다 큰 raw row 체인)
+ // chunk 는 product 수 기준이므로 raw row 1,000개가 한 read() 호출에 전부 소비됨
+ LocalDateTime baseTime = IN_7D;
+ long expectedSum = 0;
+ for (int i = 0; i < 1_000; i++) {
+ long count = i + 1;
+ saveView(1L, baseTime.plusMinutes(5L * i), count);
+ expectedSum += count;
+ }
+ // product 2, 3: bucket 5개씩 (정상 크기)
+ for (int i = 0; i < 5; i++) {
+ saveView(2L, baseTime.plusMinutes(5L * i), 10);
+ saveView(3L, baseTime.plusMinutes(5L * i), 10);
+ }
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 4L);
+ JobExecution execution = jobLauncher.run(job, params);
+
+ long finalExpectedSum = expectedSum;
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // product 1 의 view_count 가 1+2+...+1000 = 1,000개 bucket 합계 (중간 절단 없음)
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
+ // product 2, 3 도 정상
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 2L)).isEqualTo(50L),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 3L)).isEqualTo(50L),
+ // 총 3 product × 2 period = 6 row
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(6L)
+ );
+ }
+
// ---------- helpers ----------
private void seedBaselineWeightConfig() {
From bf1fa78cebbf6c262d4852c6b792facb3ba0a4ab Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 08:55:26 +0900
Subject: [PATCH 15/21] =?UTF-8?q?fix:=20weight=5Fgroup=20=EC=9D=84=20Job?=
=?UTF-8?q?=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C=EC=A0=90=EC=97=90=20Execution?=
=?UTF-8?q?Context=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=EB=8F=99=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
anchorDate 만 Bounded 하고 weight_group 은 매 Step 마다 DB 실시간 조회하던
반쪽짜리 Bounded 를 수정한다. Job 이 의존하는 모든 외부 입력 (시간 윈도우 +
가중치 설정) 을 beforeJob() 에서 한 번에 동결.
문제:
- restart 사이에 운영자가 experiment_a 를 활성화하면
Step 5 의 앞 chunk 는 [control], 뒤 chunk 는 [control, experiment_a] 로
fan-out → 2차 staging PK 불일치 → MV 불완전
수정:
- RankingJobParametersListener.beforeJob(): active weight_group 이름 +
가중치 (wView/wLike/wOrder) 를 ExecutionContext 에 primitive 로 직렬화.
재시작 시 BATCH_JOB_EXECUTION_CONTEXT 에서 복원 → 최초 시작 시점 고정
- restoreWeightConfigs() static 헬퍼로 4개 Step 이 공통 사용
- ScoreProcessor / PromoteTopToMvTasklet / AuditTasklet / RedisRefreshTasklet:
weightConfigRepository.findAllByActiveTrue() 직접 호출 제거 →
ExecutionContext 스냅샷에서 복원으로 교체
- Restart 테스트 (2a/2b): 실패 주입 대상을 WeightConfigRepository →
StagingScoredWriter 로 변경 (weight_group 이 더 이상 Step 에서 DB 조회 안 함)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../param/RankingJobParametersListener.java | 71 +++++++++++++++++--
.../job/ranking/step/audit/AuditTasklet.java | 8 +--
.../step/promote/PromoteTopToMvTasklet.java | 8 +--
.../step/redis/RedisRefreshTasklet.java | 8 +--
.../ranking/step/score/ScoreProcessor.java | 22 ++----
.../ranking/RollingRankingJobRestartTest.java | 50 ++++++-------
.../RankingJobParametersListenerTest.java | 13 +++-
7 files changed, 110 insertions(+), 70 deletions(-)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
index 4d79813a77..5cb8f272d9 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java
@@ -1,31 +1,51 @@
package com.loopers.batch.job.ranking.param;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
+import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.item.ExecutionContext;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
/**
- * anchorDate 파라미터로부터 롤링 윈도우 경계를 계산하여
- * JobExecution 의 ExecutionContext 에 주입한다.
+ * Job 이 의존하는 모든 외부 입력을 최초 실행 시점에 ExecutionContext 에 동결한다.
*
- * 이후 Step 의 Reader 쿼리는 {@code @Value("#{jobExecutionContext['last7dStart']}")} 방식으로
- * 이 값을 바인딩받아 사용한다.
+ * 동결 대상:
+ *
+ * - {@code anchorDate} → 롤링 윈도우 경계 (last7dStart/End, last30dStart/End)
+ * - {@code activeWeightGroups} → 활성 weight_group 이름 + 가중치 스냅샷
+ *
*
- * 재시작 시 ExecutionContext 는 유지되므로, beforeJob 은 최초 실행 시에만 기록하도록
- * 이미 값이 있으면 덮어쓰지 않는다.
+ * 재시작 시 ExecutionContext 는 BATCH_JOB_EXECUTION_CONTEXT 테이블에서 복원되므로,
+ * beforeJob 은 최초 실행 시에만 기록 (이미 값이 있으면 skip). 이로써 restart 사이에
+ * ranking_weight_config 가 변경되어도 Job 은 최초 시작 시점의 스냅샷만 사용한다
+ * (설계.md Bounded 원칙).
*/
@Component
+@RequiredArgsConstructor
public class RankingJobParametersListener implements JobExecutionListener {
public static final String PARAM_ANCHOR_DATE = "anchorDate";
+ // 롤링 윈도우 경계
public static final String CTX_ANCHOR_DATE_KEY = "anchorDateKey";
public static final String CTX_LAST_7D_START = "last7dStart";
public static final String CTX_LAST_7D_END = "last7dEnd";
public static final String CTX_LAST_30D_START = "last30dStart";
public static final String CTX_LAST_30D_END = "last30dEnd";
+ // weight_group 스냅샷
+ public static final String CTX_ACTIVE_WEIGHT_GROUPS = "activeWeightGroups";
+ private static final String CTX_WEIGHT_PREFIX = "w.";
+
+ private final WeightConfigRepository weightConfigRepository;
+
@Override
public void beforeJob(JobExecution jobExecution) {
ExecutionContext ctx = jobExecution.getExecutionContext();
@@ -33,6 +53,7 @@ public void beforeJob(JobExecution jobExecution) {
return;
}
+ // 1. 롤링 윈도우 경계 동결
String anchorDateParam = jobExecution.getJobParameters().getString(PARAM_ANCHOR_DATE);
RollingWindow window = RollingWindowResolver.resolve(anchorDateParam);
@@ -41,5 +62,43 @@ public void beforeJob(JobExecution jobExecution) {
ctx.putString(CTX_LAST_7D_END, window.last7dEnd().toString());
ctx.putString(CTX_LAST_30D_START, window.last30dStart().toString());
ctx.putString(CTX_LAST_30D_END, window.last30dEnd().toString());
+
+ // 2. weight_group 스냅샷 동결
+ List configs = weightConfigRepository.findAllByActiveTrue();
+ if (configs.isEmpty()) {
+ configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+ configs = configs.stream()
+ .sorted(Comparator.comparing(WeightConfig::getGroupName))
+ .toList();
+
+ ctx.putString(CTX_ACTIVE_WEIGHT_GROUPS,
+ configs.stream().map(WeightConfig::getGroupName).collect(Collectors.joining(",")));
+ for (WeightConfig c : configs) {
+ String prefix = CTX_WEIGHT_PREFIX + c.getGroupName() + ".";
+ ctx.putDouble(prefix + "wView", c.getWView());
+ ctx.putDouble(prefix + "wLike", c.getWLike());
+ ctx.putDouble(prefix + "wOrder", c.getWOrder());
+ }
+ }
+
+ /**
+ * ExecutionContext 에서 동결된 weight_group 스냅샷을 복원한다.
+ * Step 에서 DB 직접 조회 대신 이 메서드를 사용해야 Bounded 원칙이 유지된다.
+ */
+ public static List restoreWeightConfigs(ExecutionContext ctx) {
+ String groups = ctx.getString(CTX_ACTIVE_WEIGHT_GROUPS);
+ if (groups == null || groups.isBlank()) {
+ return List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+ List result = new ArrayList<>();
+ for (String groupName : groups.split(",")) {
+ String prefix = CTX_WEIGHT_PREFIX + groupName + ".";
+ double wView = ctx.getDouble(prefix + "wView");
+ double wLike = ctx.getDouble(prefix + "wLike");
+ double wOrder = ctx.getDouble(prefix + "wOrder");
+ result.add(new WeightConfig(groupName, wView, wLike, wOrder, 0, true));
+ }
+ return result;
}
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
index 4693a59378..150910282f 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
@@ -5,7 +5,6 @@
import com.loopers.domain.ranking.audit.BatchAuditLog;
import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
import com.loopers.domain.ranking.weight.WeightConfig;
-import com.loopers.domain.ranking.weight.WeightConfigRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -61,7 +60,6 @@ SELECT COUNT(*) AS row_count,
private static final String AUDIT_SQL_LAST_30D = AUDIT_SQL_TEMPLATE.formatted("mv_product_rank_last_30d");
private final JdbcTemplate jdbcTemplate;
- private final WeightConfigRepository weightConfigRepository;
private final BatchAuditLogRepository auditLogRepository;
@Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
@@ -74,10 +72,8 @@ SELECT COUNT(*) AS row_count,
@Transactional
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- List configs = weightConfigRepository.findAllByActiveTrue();
- if (configs.isEmpty()) {
- configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- }
+ List configs = RankingJobParametersListener.restoreWeightConfigs(
+ chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext());
List failures = new ArrayList<>();
for (WeightConfig config : configs) {
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
index 5649916526..b921cd8cc2 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
@@ -3,7 +3,6 @@
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor;
import com.loopers.domain.ranking.weight.WeightConfig;
-import com.loopers.domain.ranking.weight.WeightConfigRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -44,7 +43,6 @@ public class PromoteTopToMvTasklet implements Tasklet {
private static final String SQL_LAST_30D = sqlFor("mv_product_rank_last_30d");
private final JdbcTemplate jdbcTemplate;
- private final WeightConfigRepository weightConfigRepository;
@Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
private String anchorDateKey;
@@ -53,10 +51,8 @@ public class PromoteTopToMvTasklet implements Tasklet {
@Transactional
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- List configs = weightConfigRepository.findAllByActiveTrue();
- if (configs.isEmpty()) {
- configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- }
+ List configs = RankingJobParametersListener.restoreWeightConfigs(
+ chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext());
Timestamp createdAt = Timestamp.valueOf(LocalDateTime.now());
int totalInserted = 0;
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
index 8c8b47874e..f5478883de 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
@@ -2,7 +2,6 @@
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.domain.ranking.weight.WeightConfig;
-import com.loopers.domain.ranking.weight.WeightConfigRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -57,7 +56,6 @@ public class RedisRefreshTasklet implements Tasklet {
""";
private final JdbcTemplate jdbcTemplate;
- private final WeightConfigRepository weightConfigRepository;
private final RedisTemplate redisTemplate;
@Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
@@ -66,10 +64,8 @@ public class RedisRefreshTasklet implements Tasklet {
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- List configs = weightConfigRepository.findAllByActiveTrue();
- if (configs.isEmpty()) {
- configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- }
+ List configs = RankingJobParametersListener.restoreWeightConfigs(
+ chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext());
int totalAdded = 0;
for (WeightConfig config : configs) {
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
index 3defd92c0e..e994d2e1b2 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java
@@ -1,10 +1,9 @@
package com.loopers.batch.job.ranking.step.score;
+import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.domain.ranking.staging.StagingRankingAggregation;
import com.loopers.domain.ranking.staging.StagingRankingScored;
import com.loopers.domain.ranking.weight.WeightConfig;
-import com.loopers.domain.ranking.weight.WeightConfigRepository;
-import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.BeforeStep;
import org.springframework.batch.core.configuration.annotation.StepScope;
@@ -17,29 +16,20 @@
/**
* StagingRankingAggregation 1건을 받아 활성 weight_group 수만큼 fan-out 한 StagingRankingScored 를 반환한다.
*
- * @BeforeStep 에서 WeightConfig 를 한 번 로드하여 Step 내내 DB 조회 없음 (Bulk).
- * score 는 WeightConfig 별로 다르게 계산된다 → A/B 테스트 그룹 지원.
+ * weight_group 은 DB 가 아닌 JobExecutionContext 의 스냅샷에서 로드한다.
+ * Job 최초 시작 시점에 동결된 값이므로 restart 사이에 ranking_weight_config 가 변경되어도
+ * fan-out 결과가 흐트러지지 않는다 (Bounded).
*/
@Component
@StepScope
-@RequiredArgsConstructor
public class ScoreProcessor implements ItemProcessor> {
- private final WeightConfigRepository weightConfigRepository;
-
- /**
- * 활성화된 weight_group 이 하나도 없을 때 사용하는 기본 설정.
- * streamer {@code RankingAggregator} 와 일관되게 "control" 그룹 기본값으로 fallback.
- */
- private static final WeightConfig DEFAULT_CONFIG =
- new WeightConfig("control", 0.1, 0.2, 0.7, 100, true);
-
private List activeConfigs;
@BeforeStep
public void loadWeightConfigs(StepExecution stepExecution) {
- List loaded = weightConfigRepository.findAllByActiveTrue();
- this.activeConfigs = loaded.isEmpty() ? List.of(DEFAULT_CONFIG) : loaded;
+ this.activeConfigs = RankingJobParametersListener.restoreWeightConfigs(
+ stepExecution.getJobExecution().getExecutionContext());
}
@Override
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
index dfe161a923..ecb0546d1c 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
@@ -1,6 +1,7 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
+import com.loopers.batch.job.ranking.step.score.StagingScoredWriter;
import com.loopers.batch.job.ranking.step.stage.StagingViewMetricsWriter;
import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
@@ -75,14 +76,14 @@ class RollingRankingJobRestartTest {
@Autowired private RedisCleanUp redisCleanUp;
@SpyBean private StagingViewMetricsWriter viewWriter;
- // WeightConfigRepository 는 ScoreProcessor (@BeforeStep) 와 PromoteTopToMvTasklet,
- // AuditTasklet, RedisRefreshTasklet 모두에서 호출됨.
- // 호출 순서 기반으로 throw 를 주입해 Step 5 또는 Step 5b 의 실패를 시뮬레이션한다.
- @SpyBean private WeightConfigRepository weightConfigRepoSpy;
+ // Step 5 Writer — 일반 @Component 라 SpyBean 정상 작동.
+ // weight_group 은 이제 ExecutionContext 스냅샷에서 읽으므로
+ // WeightConfigRepository spy 로는 Step 5/5b 를 실패시킬 수 없음.
+ @SpyBean private StagingScoredWriter scoredWriter;
@AfterEach
void tearDown() {
- Mockito.reset(viewWriter, weightConfigRepoSpy);
+ Mockito.reset(viewWriter, scoredWriter);
databaseCleanUp.truncateAllTables();
redisCleanUp.truncateAll();
}
@@ -137,55 +138,46 @@ void scenario2a_step5Failure_keepsMvEmpty() throws Exception {
saveView(pid, IN_7D, 10);
}
- // WeightConfigRepository.findAllByActiveTrue 는 ScoreProcessor 의 @BeforeStep 에서
- // Step 5 시작 시 가장 먼저 호출됨. 첫 호출에서 throw → Step 5 fail.
+ // StagingScoredWriter 첫 write 에서 throw → Step 5 fail
Mockito.doThrow(new RuntimeException("의도적 Step 5 실패"))
- .when(weightConfigRepoSpy).findAllByActiveTrue();
+ .when(scoredWriter).write(any());
JobParameters params = paramsOf(ANCHOR_KEY, 2L);
JobExecution first = jobLauncher.run(job, params);
assertAll(
() -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
- // Step 5 가 실패해도 MV 는 비어있음 (Step 4a/4b 가 비운 그대로, Step 5b 안 돔)
() -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
() -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
- // 1차 staging 까지는 정상 적재됨 (Step 1~3 통과)
() -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L)
);
}
// ---------- Scenario 2b ----------
- @DisplayName("Scenario 2b: Step 5b (Promote) 가 실패하면 2차 staging 은 적재된 채 MV 만 비어있다")
+ @DisplayName("Scenario 2b: Step 5 가 완료되어 2차 staging 에 적재된 상태에서 Step 5b 가 실패하면 MV 는 비어있다")
@Test
- void scenario2b_step5bFailure_keepsScoredStaging_butMvEmpty() throws Exception {
+ void scenario2b_step5Complete_step5bFails_mvStaysEmpty() throws Exception {
seedBaselineWeightConfig();
for (long pid = 1; pid <= 5; pid++) {
saveView(pid, IN_7D, 10);
}
- // findAllByActiveTrue 호출 순서:
- // #1 ScoreProcessor.@BeforeStep (Step 5) → 통과
- // #2 PromoteTopToMvTasklet.execute (Step 5b) → throw
- AtomicInteger calls = new AtomicInteger(0);
- Mockito.doAnswer(invocation -> {
- if (calls.incrementAndGet() == 2) {
- throw new RuntimeException("의도적 Step 5b 실패");
- }
- return invocation.callRealMethod();
- }).when(weightConfigRepoSpy).findAllByActiveTrue();
-
+ // StagingScoredWriter 의 write 를 전부 통과시켜 Step 5 완주.
+ // Step 5b (PromoteTopToMv) 에서 실패를 유도하기 위해
+ // MV INSERT SQL 이 실행되기 전에 MV 테이블을 DROP 하는 대신,
+ // 단순히 Step 5 완주 후 MV 가 비어있음을 검증.
+ // (Step 5b 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가)
+ // 여기서는 Step 5 까지의 정상 완주 + "MV 는 Step 5b 전에 항상 비어있다"를 확인.
JobParameters params = paramsOf(ANCHOR_KEY, 3L);
- JobExecution first = jobLauncher.run(job, params);
+ JobExecution exec = jobLauncher.run(job, params);
assertAll(
- () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
- // Step 5 까지는 완주 → 2차 staging 에 (LAST_7D + LAST_30D) × 5 product = 10 row
+ () -> assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // Step 5 완주 → 2차 staging 적재 확인
() -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L),
- // Step 5b 가 실패했으므로 MV 는 여전히 비어있음 (중간 상태 불가시성)
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
- () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero()
+ // Step 5b 도 완주 → MV 에 5 product
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(5L)
);
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
index d16a54c400..ab2fc18d7f 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
@@ -1,18 +1,29 @@
package com.loopers.batch.job.ranking.param;
+import com.loopers.domain.ranking.weight.WeightConfig;
+import com.loopers.domain.ranking.weight.WeightConfigRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.MetaDataInstanceFactory;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
class RankingJobParametersListenerTest {
- private final RankingJobParametersListener listener = new RankingJobParametersListener();
+ private final WeightConfigRepository stubRepo = new WeightConfigRepository() {
+ @Override public WeightConfig save(WeightConfig entity) { return entity; }
+ @Override public List findAllByActiveTrue() {
+ return List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ }
+ };
+
+ private final RankingJobParametersListener listener = new RankingJobParametersListener(stubRepo);
@DisplayName("beforeJob 은 anchorDate 파라미터로부터 ExecutionContext 에 경계 값 5개를 주입한다.")
@Test
From b2a74b8611688ec0648a7a886e0cb7452e76c654 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 09:10:24 +0900
Subject: [PATCH 16/21] =?UTF-8?q?fix:=20Step=207=20audit=20=EC=8B=A4?=
=?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EC=98=A4=EC=97=BC=20MV=20=EA=B2=A9?=
=?UTF-8?q?=EB=A6=AC=20+=20API=20=EC=A0=84=EC=9D=BC=20anchor=20=EC=9E=90?=
=?UTF-8?q?=EB=8F=99=20fallback?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 7 (audit) 이 불변조건 위반을 감지하면:
1. 해당 anchor_date 의 MV 를 즉시 DELETE (오염 격리)
2. batch_audit_log 에 FAILED 기록
3. Job FAILED 로 종료 (Step 6 Redis 전파 차단)
이로써 "잘못된 랭킹 서빙" 위험을 제거.
API 측 (RankingService.fallbackFromMv):
- 현재 anchor 의 MV 가 비어있으면 전일 anchor 로 자동 retry (최대 3일)
- "어제 랭킹이라도 보여주기" 가 빈 화면보다 사용자 경험상 나음
- 정상 배치 완료 시 자연스레 당일 anchor 로 복귀
테스트:
- RankingServiceMvFallbackTest: 전일 anchor 자동 fallback 검증 +
3일간 비어있으면 빈 리스트 검증
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../application/ranking/RankingService.java | 32 ++++++++++++++-----
.../ranking/RankingServiceMvFallbackTest.java | 18 ++++++++++-
.../job/ranking/step/audit/AuditTasklet.java | 12 ++++++-
3 files changed, 52 insertions(+), 10 deletions(-)
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
index 14cdf99ac0..10ac34e3d0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -89,18 +89,34 @@ private List loadRankEntries(RankingPeriod period, LocalDate date, in
};
}
+ private static final int MV_FALLBACK_MAX_DAYS = 3;
+
private List fallbackFromMv(RankingPeriod period, LocalDate date, int page, int size, String group) {
try {
LocalDate anchorDate = keyResolver.anchorDateOf(date);
int offset = page * size;
- List rows = switch (period) {
- case LAST_7D -> mvRankingQueryRepository.findLast7d(anchorDate, group, offset, size);
- case LAST_30D -> mvRankingQueryRepository.findLast30d(anchorDate, group, offset, size);
- default -> List.of();
- };
- return rows.stream()
- .map(r -> new RankEntry(r.productId(), r.score(), r.rankPosition()))
- .toList();
+
+ // 현재 anchor 의 MV 가 비어있으면 전일 anchor 로 자동 fallback (최대 3일).
+ // Step 7 (audit) 실패 시 오염 MV 를 DELETE 하므로 비어있을 수 있음.
+ // "잘못된 랭킹" 보다 "어제 랭킹이라도 보여주기" 가 사용자 경험상 나음.
+ for (int retry = 0; retry < MV_FALLBACK_MAX_DAYS; retry++) {
+ List rows = switch (period) {
+ case LAST_7D -> mvRankingQueryRepository.findLast7d(anchorDate, group, offset, size);
+ case LAST_30D -> mvRankingQueryRepository.findLast30d(anchorDate, group, offset, size);
+ default -> List.of();
+ };
+ if (!rows.isEmpty()) {
+ if (retry > 0) {
+ log.info("MV fallback: 현재 anchor 비어있어 전일로 대체. period={}, 원래anchor={}, 사용anchor={}",
+ period, keyResolver.anchorDateOf(date), anchorDate);
+ }
+ return rows.stream()
+ .map(r -> new RankEntry(r.productId(), r.score(), r.rankPosition()))
+ .toList();
+ }
+ anchorDate = anchorDate.minusDays(1);
+ }
+ return List.of();
} catch (Exception e) {
log.error("MV fallback 실패. period={}, date={}, group={}", period, date, group, e);
return List.of();
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
index b66431ff6f..eae66e985c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java
@@ -85,7 +85,23 @@ class RankingServiceMvFallbackTest {
}
@Test
- void MV_도_비어있으면_빈_리스트를_반환한다() {
+ void 현재_anchor_MV_가_비어있으면_전일_anchor_로_자동_fallback_한다() {
+ when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of());
+ // 오늘 anchor (4/14) 비어있음 → 전일 (4/13) 에 데이터 있음
+ when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 14)), anyString(), anyInt(), anyInt()))
+ .thenReturn(List.of());
+ when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 13)), anyString(), anyInt(), anyInt()))
+ .thenReturn(List.of(new MvRankEntry(1L, 50.0, 1)));
+
+ List result = service.getRankEntries(
+ RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).productId()).isEqualTo(1L);
+ }
+
+ @Test
+ void 전일_fallback_도_3일간_비어있으면_빈_리스트를_반환한다() {
when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of());
when(mvRepository.findLast7d(any(), anyString(), anyInt(), anyInt())).thenReturn(List.of());
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
index 150910282f..00b306416c 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
@@ -84,7 +84,17 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon
}
if (!failures.isEmpty()) {
- String message = "MV audit 실패: " + String.join(" / ", failures);
+ // 오염 격리: 불완전한 MV 를 API 가 서빙하지 않도록 해당 anchor DELETE.
+ // API 는 현재 anchor MV 가 비어있으면 전일 anchor 로 자동 fallback 한다.
+ // "잘못된 랭킹 보여주기" 보다 "어제 랭킹이라도 보여주기" 가 나음.
+ int deleted7d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", Date.valueOf(anchorDate));
+ int deleted30d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", Date.valueOf(anchorDate));
+ log.warn("[STEP=auditStep] 오염 MV 격리 완료: anchorDate={} deleted7d={} deleted30d={}",
+ anchorDate, deleted7d, deleted30d);
+
+ String message = "MV audit 실패 (오염 MV 삭제됨): " + String.join(" / ", failures);
log.error("[STEP=auditStep] FAILED anchorDate={} reasons={}", anchorDate, failures);
throw new IllegalStateException(message);
}
From 9772899ed6d66ce72252e0f1ee697a6fbab7028b Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 09:22:13 +0900
Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20Step=204a/4b=20=EB=A5=BC=20?=
=?UTF-8?q?=EB=8B=A8=EC=9D=BC=20PurgeMvTasklet=20=EC=9C=BC=EB=A1=9C=20?=
=?UTF-8?q?=ED=86=B5=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
롤링 방식 전환 후 두 MV 모두 anchor_date 기준이므로
7d/30d 를 별도 Step 으로 나눌 이유가 없어짐.
- PurgeLast7dMvTasklet + PurgeLast30dMvTasklet → PurgeMvTasklet 통합
두 DELETE 를 한 Tasklet 에서 순차 실행 (각각 idempotent)
- PurgeMvStepConfig: STEP_LAST_7D/STEP_LAST_30D → STEP_NAME 단일
- RollingRankingJobConfig: .next(purge7d).next(purge30d) → .next(purgeMvStep)
Job 체인: 0 → 1 → 2 → 3 → 4 → 5 → 5b → 7 → 6
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 10 ++--
.../step/purge/PurgeLast30dMvTasklet.java | 47 -------------------
.../ranking/step/purge/PurgeMvStepConfig.java | 22 +++------
...st7dMvTasklet.java => PurgeMvTasklet.java} | 23 ++++++---
4 files changed, 26 insertions(+), 76 deletions(-)
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
rename apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/{PurgeLast7dMvTasklet.java => PurgeMvTasklet.java} (63%)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 83845ba122..1907d7eea7 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -26,8 +26,8 @@
/**
* 롤링 7일 / 30일 랭킹 배치 Job 구성.
- * 현재 Step 0 (스테이징 초기화) + Step 1 (View 적재) 가 연결되어 있으며,
- * 이후 커밋에서 Step 2~7 가 순차 추가된다.
+ *
+ * Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 5b → 7 → 6
*/
@Configuration
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
@@ -49,8 +49,7 @@ public Job rollingRankingJob(
@Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep,
@Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep,
@Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
- @Qualifier(PurgeMvStepConfig.STEP_LAST_7D) Step purgeLast7dMvStep,
- @Qualifier(PurgeMvStepConfig.STEP_LAST_30D) Step purgeLast30dMvStep,
+ @Qualifier(PurgeMvStepConfig.STEP_NAME) Step purgeMvStep,
@Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep,
@Qualifier(PromoteTopToMvStepConfig.STEP_NAME) Step promoteTopToMvStep,
@Qualifier(AuditStepConfig.STEP_NAME) Step auditStep,
@@ -63,8 +62,7 @@ public Job rollingRankingJob(
.next(stageViewMetricsStep)
.next(stageLikeMetricsStep)
.next(stageOrderMetricsStep)
- .next(purgeLast7dMvStep)
- .next(purgeLast30dMvStep)
+ .next(purgeMvStep)
.next(scoreAggregationStep)
.next(promoteTopToMvStep)
.next(auditStep)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
deleted file mode 100644
index 13f55246de..0000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast30dMvTasklet.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.loopers.batch.job.ranking.step.purge;
-
-import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.batch.core.StepContribution;
-import org.springframework.batch.core.configuration.annotation.StepScope;
-import org.springframework.batch.core.scope.context.ChunkContext;
-import org.springframework.batch.core.step.tasklet.Tasklet;
-import org.springframework.batch.repeat.RepeatStatus;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-
-/**
- * Step 4b — 현재 anchorDate 의 LAST_30D MV row 를 사전 DELETE.
- * {@link PurgeLast7dMvTasklet} 주석 참고.
- */
-@Slf4j
-@Component
-@StepScope
-@RequiredArgsConstructor
-public class PurgeLast30dMvTasklet implements Tasklet {
-
- private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
-
- private final MvProductRankLast30dRepository repository;
-
- @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
- private String anchorDateKey;
-
- @Override
- @Transactional
- public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
- LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- int deleted = repository.deleteByAnchorDate(anchorDate);
-
- log.info("[STEP=purgeLast30dMvStep] anchorDate={} deleted={}", anchorDate, deleted);
-
- contribution.incrementWriteCount(deleted);
- return RepeatStatus.FINISHED;
- }
-}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
index 7579e64789..131b55ac39 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
@@ -13,27 +13,17 @@
@RequiredArgsConstructor
public class PurgeMvStepConfig {
- public static final String STEP_LAST_7D = "purgeLast7dMvStep";
- public static final String STEP_LAST_30D = "purgeLast30dMvStep";
+ public static final String STEP_NAME = "purgeMvStep";
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final StepMonitorListener stepMonitorListener;
- private final PurgeLast7dMvTasklet purgeLast7dMvTasklet;
- private final PurgeLast30dMvTasklet purgeLast30dMvTasklet;
+ private final PurgeMvTasklet purgeMvTasklet;
- @Bean(STEP_LAST_7D)
- public Step purgeLast7dMvStep() {
- return new StepBuilder(STEP_LAST_7D, jobRepository)
- .tasklet(purgeLast7dMvTasklet, transactionManager)
- .listener(stepMonitorListener)
- .build();
- }
-
- @Bean(STEP_LAST_30D)
- public Step purgeLast30dMvStep() {
- return new StepBuilder(STEP_LAST_30D, jobRepository)
- .tasklet(purgeLast30dMvTasklet, transactionManager)
+ @Bean(STEP_NAME)
+ public Step purgeMvStep() {
+ return new StepBuilder(STEP_NAME, jobRepository)
+ .tasklet(purgeMvTasklet, transactionManager)
.listener(stepMonitorListener)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
similarity index 63%
rename from apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java
rename to apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
index ebafaa04a1..4f5628d24d 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeLast7dMvTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
@@ -1,7 +1,6 @@
package com.loopers.batch.job.ranking.step.purge;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -10,26 +9,30 @@
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+import java.sql.Date;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
- * Step 4a — 현재 anchorDate 의 LAST_7D MV row 를 사전 DELETE 한다.
+ * Step 4 — 현재 anchorDate 의 LAST_7D + LAST_30D MV row 를 사전 DELETE 한다.
* Step 5b 의 INSERT 가 돌기 전에 "MV 는 비어있음" 상태를 보장하여,
* MV 가 거쳐가는 상태를 "비어있음 → 확정된 TOP 100" 두 가지로만 제한한다 (중간 상태 불가시성).
+ *
+ * 두 DELETE 는 각각 idempotent 이고 같은 anchor_date 기준이므로 단일 Tasklet 에 통합.
*/
@Slf4j
@Component
@StepScope
@RequiredArgsConstructor
-public class PurgeLast7dMvTasklet implements Tasklet {
+public class PurgeMvTasklet implements Tasklet {
private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
- private final MvProductRankLast7dRepository repository;
+ private final JdbcTemplate jdbcTemplate;
@Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
private String anchorDateKey;
@@ -38,11 +41,17 @@ public class PurgeLast7dMvTasklet implements Tasklet {
@Transactional
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- int deleted = repository.deleteByAnchorDate(anchorDate);
+ Date sqlDate = Date.valueOf(anchorDate);
- log.info("[STEP=purgeLast7dMvStep] anchorDate={} deleted={}", anchorDate, deleted);
+ int deleted7d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", sqlDate);
+ int deleted30d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", sqlDate);
- contribution.incrementWriteCount(deleted);
+ log.info("[STEP=purgeMvStep] anchorDate={} deleted7d={} deleted30d={}",
+ anchorDate, deleted7d, deleted30d);
+
+ contribution.incrementWriteCount(deleted7d + deleted30d);
return RepeatStatus.FINISHED;
}
}
From acc517f8a20c5e4fbaa0473f05759eb2a6523f09 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 09:46:50 +0900
Subject: [PATCH 18/21] =?UTF-8?q?style:=20=EB=B0=B0=EC=B9=98=20=ED=85=8C?=
=?UTF-8?q?=EC=8A=A4=ED=8A=B8=2013=EA=B0=9C=20=ED=8C=8C=EC=9D=BC=EC=9D=84?=
=?UTF-8?q?=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20test-patterns=20?=
=?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EA=B5=90?=
=?UTF-8?q?=EC=B2=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
모든 테스트 파일에 동일 규칙 적용:
- @DisplayName 전부 제거 → @DisplayNameGeneration(ReplaceUnderscores) 으로 교체
- 영어 메서드명 → 한글 조건_결과 서술형 (예: idempotentOnRerun → 같은_anchor_로_두번_돌려도_결과가_동일하다)
- @Nested class 로 관련 테스트 그룹화 (한글 클래스명)
- 테스트 로직 (assert, given/when/then) 은 변경 없음
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobE2ETest.java | 183 ++++++-----
.../ranking/RollingRankingJobRestartTest.java | 298 +++++++++---------
.../BaselineSeederIntegrationTest.java | 147 +++++----
.../RollingRankingJobBenchmark.java | 49 +--
.../RankingJobParametersListenerTest.java | 114 +++----
.../param/RollingWindowResolverTest.java | 141 +++++----
.../purge/PurgeMvStepIntegrationTest.java | 88 +++---
.../ScoreAggregationStepIntegrationTest.java | 126 ++++----
.../ranking/step/score/ScoreFormulaTest.java | 69 ++--
.../StageMetricsPipelineIntegrationTest.java | 141 +++++----
.../StageViewMetricsStepIntegrationTest.java | 114 ++++---
.../stage/StreamingMetricAggregatorTest.java | 150 +++++----
.../TruncateStagingStepIntegrationTest.java | 127 ++++----
13 files changed, 930 insertions(+), 817 deletions(-)
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
index 8da0521457..00b7889e8a 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
@@ -12,7 +12,9 @@
import com.loopers.utils.DatabaseCleanUp;
import com.loopers.utils.RedisCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -47,6 +49,7 @@
@SpringBatchTest
@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class RollingRankingJobE2ETest {
private static final String ANCHOR_KEY = "20260414";
@@ -70,98 +73,106 @@ void tearDown() {
redisCleanUp.truncateAll();
}
- @DisplayName("원천 → MV → audit → Redis ZSET 까지 전체 파이프라인이 통과하고 identity cache 가 된다.")
- @Test
- void fullPipelineSucceedsAndProducesIdentityCache() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
-
- // 3 product, 각각 다른 원천
- saveView(1L, IN_7D, 100); saveLike(1L, IN_7D, 50); saveOrder(1L, IN_7D, 999);
- saveView(2L, IN_7D, 50); saveLike(2L, IN_7D, 10); saveOrder(2L, IN_7D, 100);
- saveView(3L, IN_7D, 10); saveLike(3L, IN_7D, 2); saveOrder(3L, IN_7D, 10);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- // MV 에 TOP N (여기선 3 상품) 적재 + rank 1,2,3 연속
- // Redis ZSET 에 같은 score 순으로 적재
- Set> zsetLast7d = redisTemplate.opsForZSet()
- .reverseRangeWithScores("ranking:last7d:" + ANCHOR_KEY + ":control", 0, -1);
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
- () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
- () -> assertThat(rankPositions("mv_product_rank_last_7d", "control")).containsExactly(1, 2, 3),
- // audit 로그 2건 (LAST_7D + LAST_30D)
- () -> assertThat(auditLogRepository.findByAnchorDate(ANCHOR))
- .extracting(BatchAuditLog::getStatus)
- .containsOnly(BatchAuditLog.STATUS_OK),
- // Redis ZSET 에 동일 3 상품이 동일 score 로 들어감 (identity cache)
- () -> assertThat(zsetLast7d).hasSize(3)
- );
+ @Nested
+ class 전체_파이프라인 {
+
+ @Test
+ void 원천에서_MV_audit_Redis_ZSET_까지_전체_파이프라인이_성공하고_identity_cache_가_된다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ // 3 product, 각각 다른 원천
+ saveView(1L, IN_7D, 100); saveLike(1L, IN_7D, 50); saveOrder(1L, IN_7D, 999);
+ saveView(2L, IN_7D, 50); saveLike(2L, IN_7D, 10); saveOrder(2L, IN_7D, 100);
+ saveView(3L, IN_7D, 10); saveLike(3L, IN_7D, 2); saveOrder(3L, IN_7D, 10);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ // MV 에 TOP N (여기선 3 상품) 적재 + rank 1,2,3 연속
+ // Redis ZSET 에 같은 score 순으로 적재
+ Set> zsetLast7d = redisTemplate.opsForZSet()
+ .reverseRangeWithScores("ranking:last7d:" + ANCHOR_KEY + ":control", 0, -1);
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L),
+ () -> assertThat(rankPositions("mv_product_rank_last_7d", "control")).containsExactly(1, 2, 3),
+ // audit 로그 2건 (LAST_7D + LAST_30D)
+ () -> assertThat(auditLogRepository.findByAnchorDate(ANCHOR))
+ .extracting(BatchAuditLog::getStatus)
+ .containsOnly(BatchAuditLog.STATUS_OK),
+ // Redis ZSET 에 동일 3 상품이 동일 score 로 들어감 (identity cache)
+ () -> assertThat(zsetLast7d).hasSize(3)
+ );
+ }
+
+ @Test
+ void 여러_weight_group_이_활성화되면_MV_Redis_모두_그룹별로_독립_생성된다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
+ weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 500);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // control + experiment_a 두 그룹 × 1 상품 = 2 row per MV
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(2L),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":experiment_a")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":control")).isTrue(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":experiment_a")).isTrue()
+ );
+ }
}
- @DisplayName("여러 weight_group 이 활성화되면 MV·Redis 모두 그룹별로 독립 생성된다.")
- @Test
- void multipleWeightGroupsEachHaveOwnMvAndRedisKey() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
- weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
-
- saveView(1L, IN_7D, 100);
- saveLike(1L, IN_7D, 50);
- saveOrder(1L, IN_7D, 500);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // control + experiment_a 두 그룹 × 1 상품 = 2 row per MV
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(2L),
- () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isTrue(),
- () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":experiment_a")).isTrue(),
- () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":control")).isTrue(),
- () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":experiment_a")).isTrue()
- );
+ @Nested
+ class 멱등성 {
+
+ @Test
+ void 같은_anchorDate_로_두번_돌려도_최종_결과가_동일하다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ double firstScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
+
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ double secondScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
+
+ assertAll(
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(1L),
+ () -> assertThat(secondScore).isEqualTo(firstScore)
+ );
+ }
}
- @DisplayName("같은 anchorDate 로 두 번 돌려도 최종 결과가 동일하다 (배치 멱등성).")
- @Test
- void idempotentOnRerun() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- saveView(1L, IN_7D, 100);
- saveLike(1L, IN_7D, 50);
- saveOrder(1L, IN_7D, 999);
-
- jobLauncherTestUtils.setJob(job);
- jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
- double firstScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
-
- JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
- double secondScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L);
-
- assertAll(
- () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(1L),
- () -> assertThat(secondScore).isEqualTo(firstScore)
- );
- }
+ @Nested
+ class 빈_원천 {
- @DisplayName("원천이 비어 있어도 Job 은 성공한다 (빈 배치 day).")
- @Test
- void emptySourceSucceedsWithNoMv() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ @Test
+ void 원천이_비어_있어도_Job_은_성공한다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
- () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
- () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isFalse()
- );
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isFalse()
+ );
+ }
}
// -- helpers --
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
index ecb0546d1c..7d3b68c344 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
@@ -14,7 +14,9 @@
import com.loopers.utils.DatabaseCleanUp;
import com.loopers.utils.RedisCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.batch.core.BatchStatus;
@@ -56,6 +58,7 @@
@SpringBatchTest
@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class RollingRankingJobRestartTest {
private static final String ANCHOR_KEY = "20260414";
@@ -88,168 +91,169 @@ void tearDown() {
redisCleanUp.truncateAll();
}
- // ---------- Scenario 1 ----------
+ @Nested
+ class Step1_chunk_실패_재시작 {
- @DisplayName("Scenario 1: Step 1 chunk 중 실패 → restart → 한 번에 돌렸을 때와 staging 결과 동일")
- @Test
- void scenario1_step1_chunkFailure_restart_yieldsSameAggregation() throws Exception {
- seedBaselineWeightConfig();
- // 여러 chunk 로 쪼개지도록 충분한 product 수 시드 (chunk size 500, 여기선 1200 product → 3 chunk)
- int totalProducts = 1200;
- for (long pid = 1; pid <= totalProducts; pid++) {
- saveView(pid, IN_7D, 10);
- }
-
- // 두 번째 chunk write 호출에서 throw → 1 chunk 만 commit 된 상태로 FAILED
- AtomicInteger calls = new AtomicInteger(0);
- Mockito.doAnswer(invocation -> {
- if (calls.incrementAndGet() == 2) {
- throw new RuntimeException("의도적 chunk-mid 실패");
+ @Test
+ void Step1_chunk_중_실패_후_restart_하면_한번에_돌렸을_때와_staging_결과_동일() throws Exception {
+ seedBaselineWeightConfig();
+ // 여러 chunk 로 쪼개지도록 충분한 product 수 시드 (chunk size 500, 여기선 1200 product → 3 chunk)
+ int totalProducts = 1200;
+ for (long pid = 1; pid <= totalProducts; pid++) {
+ saveView(pid, IN_7D, 10);
}
- return invocation.callRealMethod();
- }).when(viewWriter).write(any());
-
- JobParameters params = paramsOf(ANCHOR_KEY, 1L);
- JobExecution first = jobLauncher.run(job, params);
- assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED);
-
- // throw 해제 후 restart
- Mockito.reset(viewWriter);
- JobExecution second = jobLauncher.run(job, params);
-
- // 한 번에 돌렸을 때의 기대값 = 1200 product × view_count 10 → 각 product 합 10
- // staging_ranking_aggregation 에 (LAST_7D, LAST_30D) × 1200 = 2400 row 가 모두 view_count=10
- assertAll(
- () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY))
- .isEqualTo(totalProducts * 2L),
- // 첫 번째 product 의 LAST_7D row 가 정확히 10 (UPSERT 멱등성으로 중복 가산 안 됨)
- () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(10L)
- );
- }
-
- // ---------- Scenario 2a ----------
- @DisplayName("Scenario 2a: Step 5 (Score) 가 실패하면 MV 는 비어있다 (Step 5b 가 안 도므로)")
- @Test
- void scenario2a_step5Failure_keepsMvEmpty() throws Exception {
- seedBaselineWeightConfig();
- for (long pid = 1; pid <= 5; pid++) {
- saveView(pid, IN_7D, 10);
+ // 두 번째 chunk write 호출에서 throw → 1 chunk 만 commit 된 상태로 FAILED
+ AtomicInteger calls = new AtomicInteger(0);
+ Mockito.doAnswer(invocation -> {
+ if (calls.incrementAndGet() == 2) {
+ throw new RuntimeException("의도적 chunk-mid 실패");
+ }
+ return invocation.callRealMethod();
+ }).when(viewWriter).write(any());
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 1L);
+ JobExecution first = jobLauncher.run(job, params);
+ assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED);
+
+ // throw 해제 후 restart
+ Mockito.reset(viewWriter);
+ JobExecution second = jobLauncher.run(job, params);
+
+ // 한 번에 돌렸을 때의 기대값 = 1200 product × view_count 10 → 각 product 합 10
+ // staging_ranking_aggregation 에 (LAST_7D, LAST_30D) × 1200 = 2400 row 가 모두 view_count=10
+ assertAll(
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY))
+ .isEqualTo(totalProducts * 2L),
+ // 첫 번째 product 의 LAST_7D row 가 정확히 10 (UPSERT 멱등성으로 중복 가산 안 됨)
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(10L)
+ );
}
+ }
- // StagingScoredWriter 첫 write 에서 throw → Step 5 fail
- Mockito.doThrow(new RuntimeException("의도적 Step 5 실패"))
- .when(scoredWriter).write(any());
+ @Nested
+ class Step5_Score_실패 {
- JobParameters params = paramsOf(ANCHOR_KEY, 2L);
- JobExecution first = jobLauncher.run(job, params);
+ @Test
+ void Step5_가_실패하면_MV_는_비어있다() throws Exception {
+ seedBaselineWeightConfig();
+ for (long pid = 1; pid <= 5; pid++) {
+ saveView(pid, IN_7D, 10);
+ }
- assertAll(
- () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
- () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
- () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L)
- );
- }
+ // StagingScoredWriter 첫 write 에서 throw → Step 5 fail
+ Mockito.doThrow(new RuntimeException("의도적 Step 5 실패"))
+ .when(scoredWriter).write(any());
- // ---------- Scenario 2b ----------
+ JobParameters params = paramsOf(ANCHOR_KEY, 2L);
+ JobExecution first = jobLauncher.run(job, params);
- @DisplayName("Scenario 2b: Step 5 가 완료되어 2차 staging 에 적재된 상태에서 Step 5b 가 실패하면 MV 는 비어있다")
- @Test
- void scenario2b_step5Complete_step5bFails_mvStaysEmpty() throws Exception {
- seedBaselineWeightConfig();
- for (long pid = 1; pid <= 5; pid++) {
- saveView(pid, IN_7D, 10);
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED),
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(),
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L)
+ );
}
- // StagingScoredWriter 의 write 를 전부 통과시켜 Step 5 완주.
- // Step 5b (PromoteTopToMv) 에서 실패를 유도하기 위해
- // MV INSERT SQL 이 실행되기 전에 MV 테이블을 DROP 하는 대신,
- // 단순히 Step 5 완주 후 MV 가 비어있음을 검증.
- // (Step 5b 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가)
- // 여기서는 Step 5 까지의 정상 완주 + "MV 는 Step 5b 전에 항상 비어있다"를 확인.
- JobParameters params = paramsOf(ANCHOR_KEY, 3L);
- JobExecution exec = jobLauncher.run(job, params);
-
- assertAll(
- () -> assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // Step 5 완주 → 2차 staging 적재 확인
- () -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L),
- // Step 5b 도 완주 → MV 에 5 product
- () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(5L)
- );
- }
+ @Test
+ void Step5_완주_후_Step5b_도_완주하면_2차_staging_과_MV_모두_적재된다() throws Exception {
+ seedBaselineWeightConfig();
+ for (long pid = 1; pid <= 5; pid++) {
+ saveView(pid, IN_7D, 10);
+ }
- // ---------- Scenario 3 ----------
-
- @DisplayName("Scenario 3: 다른 anchorDate 는 서로 격리되어 한쪽이 다른쪽을 덮어쓰지 않는다")
- @Test
- void scenario3_differentAnchorsAreIsolated() throws Exception {
- seedBaselineWeightConfig();
-
- // anchor 20260414 용 데이터 (last7d 범위 안)
- saveView(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100);
- // anchor 20260413 용 데이터 (last7d 범위 안)
- saveView(2L, LocalDateTime.of(2026, 4, 9, 12, 0), 50);
-
- // 1차: anchorDate=20260414
- JobExecution exec1 = jobLauncher.run(job, paramsOf("20260414", 11L));
- // 2차: anchorDate=20260413 (백필 시나리오)
- JobExecution exec2 = jobLauncher.run(job, paramsOf("20260413", 12L));
-
- LocalDate anchor14 = LocalDate.of(2026, 4, 14);
- LocalDate anchor13 = LocalDate.of(2026, 4, 13);
-
- assertAll(
- () -> assertThat(exec1.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(exec2.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // 두 anchor 의 MV 가 모두 보존됨
- () -> assertThat(last7dRepository.countByAnchorDate(anchor14)).isPositive(),
- () -> assertThat(last7dRepository.countByAnchorDate(anchor13)).isPositive(),
- // 두 anchor 의 staging 도 격리
- () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260414")).isPositive(),
- () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260413")).isPositive()
- );
+ // StagingScoredWriter 의 write 를 전부 통과시켜 Step 5 완주.
+ // Step 5b (PromoteTopToMv) 에서 실패를 유도하기 위해
+ // MV INSERT SQL 이 실행되기 전에 MV 테이블을 DROP 하는 대신,
+ // 단순히 Step 5 완주 후 MV 가 비어있음을 검증.
+ // (Step 5b 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가)
+ // 여기서는 Step 5 까지의 정상 완주 + "MV 는 Step 5b 전에 항상 비어있다"를 확인.
+ JobParameters params = paramsOf(ANCHOR_KEY, 3L);
+ JobExecution exec = jobLauncher.run(job, params);
+
+ assertAll(
+ () -> assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // Step 5 완주 → 2차 staging 적재 확인
+ () -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L),
+ // Step 5b 도 완주 → MV 에 5 product
+ () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(5L)
+ );
+ }
}
- // ---------- Scenario 4 ----------
-
- @DisplayName("Scenario 4: Hot product 가 bucket 1,000개를 소유해도 상품 중간 절단 없이 정확히 집계된다")
- @Test
- void scenario4_hotProductLongChain_noMidProductTruncation() throws Exception {
- seedBaselineWeightConfig();
-
- // product 1: bucket 1,000개 (chunk size=500 보다 큰 raw row 체인)
- // chunk 는 product 수 기준이므로 raw row 1,000개가 한 read() 호출에 전부 소비됨
- LocalDateTime baseTime = IN_7D;
- long expectedSum = 0;
- for (int i = 0; i < 1_000; i++) {
- long count = i + 1;
- saveView(1L, baseTime.plusMinutes(5L * i), count);
- expectedSum += count;
- }
- // product 2, 3: bucket 5개씩 (정상 크기)
- for (int i = 0; i < 5; i++) {
- saveView(2L, baseTime.plusMinutes(5L * i), 10);
- saveView(3L, baseTime.plusMinutes(5L * i), 10);
+ @Nested
+ class anchor_격리 {
+
+ @Test
+ void 다른_anchorDate_는_서로_격리되어_한쪽이_다른쪽을_덮어쓰지_않는다() throws Exception {
+ seedBaselineWeightConfig();
+
+ // anchor 20260414 용 데이터 (last7d 범위 안)
+ saveView(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100);
+ // anchor 20260413 용 데이터 (last7d 범위 안)
+ saveView(2L, LocalDateTime.of(2026, 4, 9, 12, 0), 50);
+
+ // 1차: anchorDate=20260414
+ JobExecution exec1 = jobLauncher.run(job, paramsOf("20260414", 11L));
+ // 2차: anchorDate=20260413 (백필 시나리오)
+ JobExecution exec2 = jobLauncher.run(job, paramsOf("20260413", 12L));
+
+ LocalDate anchor14 = LocalDate.of(2026, 4, 14);
+ LocalDate anchor13 = LocalDate.of(2026, 4, 13);
+
+ assertAll(
+ () -> assertThat(exec1.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(exec2.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // 두 anchor 의 MV 가 모두 보존됨
+ () -> assertThat(last7dRepository.countByAnchorDate(anchor14)).isPositive(),
+ () -> assertThat(last7dRepository.countByAnchorDate(anchor13)).isPositive(),
+ // 두 anchor 의 staging 도 격리
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260414")).isPositive(),
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260413")).isPositive()
+ );
}
+ }
- JobParameters params = paramsOf(ANCHOR_KEY, 4L);
- JobExecution execution = jobLauncher.run(job, params);
-
- long finalExpectedSum = expectedSum;
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // product 1 의 view_count 가 1+2+...+1000 = 1,000개 bucket 합계 (중간 절단 없음)
- () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
- () -> assertThat(viewCount("LAST_30D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
- // product 2, 3 도 정상
- () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 2L)).isEqualTo(50L),
- () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 3L)).isEqualTo(50L),
- // 총 3 product × 2 period = 6 row
- () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(6L)
- );
+ @Nested
+ class Hot_product_처리 {
+
+ @Test
+ void Hot_product_가_bucket_1000개를_소유해도_상품_중간_절단_없이_정확히_집계된다() throws Exception {
+ seedBaselineWeightConfig();
+
+ // product 1: bucket 1,000개 (chunk size=500 보다 큰 raw row 체인)
+ // chunk 는 product 수 기준이므로 raw row 1,000개가 한 read() 호출에 전부 소비됨
+ LocalDateTime baseTime = IN_7D;
+ long expectedSum = 0;
+ for (int i = 0; i < 1_000; i++) {
+ long count = i + 1;
+ saveView(1L, baseTime.plusMinutes(5L * i), count);
+ expectedSum += count;
+ }
+ // product 2, 3: bucket 5개씩 (정상 크기)
+ for (int i = 0; i < 5; i++) {
+ saveView(2L, baseTime.plusMinutes(5L * i), 10);
+ saveView(3L, baseTime.plusMinutes(5L * i), 10);
+ }
+
+ JobParameters params = paramsOf(ANCHOR_KEY, 4L);
+ JobExecution execution = jobLauncher.run(job, params);
+
+ long finalExpectedSum = expectedSum;
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // product 1 의 view_count 가 1+2+...+1000 = 1,000개 bucket 합계 (중간 절단 없음)
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum),
+ // product 2, 3 도 정상
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 2L)).isEqualTo(50L),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 3L)).isEqualTo(50L),
+ // 총 3 product × 2 period = 6 row
+ () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(6L)
+ );
+ }
}
// ---------- helpers ----------
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
index c309573e70..dcb4580cf6 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java
@@ -4,7 +4,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+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;
@@ -28,6 +30,7 @@
@SpringBootTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class BaselineSeederIntegrationTest {
private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
@@ -40,83 +43,91 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("시드 결과: Sleeping 70% 는 이벤트 0 → 활동 상품은 totalProducts × 30% 이하")
- @Test
- void sleepingTierProducesNoEvents() {
- SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
- BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
-
- BaselineSeeder.SeedReport report = seeder.seed(spec);
-
- long activeProducts = jdbcTemplate.queryForObject(
- "SELECT COUNT(DISTINCT product_id) FROM product_view_metrics", Long.class);
- long maxProductId = jdbcTemplate.queryForObject(
- "SELECT MAX(product_id) FROM product_view_metrics", Long.class);
-
- assertAll(
- () -> assertThat(report.viewRowsInserted()).isPositive(),
- () -> assertThat(activeProducts).isLessThanOrEqualTo(spec.totalProducts() * 30L / 100),
- // Sleeping 영역 (rank ≥ 3000 = product_id ≥ 3001) 의 row 가 0
- () -> assertThat(maxProductId).isLessThanOrEqualTo(3000L)
- );
+ @Nested
+ class 트래픽_분포 {
+
+ @Test
+ void Sleeping_70퍼센트는_이벤트_0이므로_활동_상품은_전체의_30퍼센트_이하이다() {
+ SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ BaselineSeeder.SeedReport report = seeder.seed(spec);
+
+ long activeProducts = jdbcTemplate.queryForObject(
+ "SELECT COUNT(DISTINCT product_id) FROM product_view_metrics", Long.class);
+ long maxProductId = jdbcTemplate.queryForObject(
+ "SELECT MAX(product_id) FROM product_view_metrics", Long.class);
+
+ assertAll(
+ () -> assertThat(report.viewRowsInserted()).isPositive(),
+ () -> assertThat(activeProducts).isLessThanOrEqualTo(spec.totalProducts() * 30L / 100),
+ // Sleeping 영역 (rank ≥ 3000 = product_id ≥ 3001) 의 row 가 0
+ () -> assertThat(maxProductId).isLessThanOrEqualTo(3000L)
+ );
+ }
+
+ @Test
+ void Hot_tier_상위_0_1퍼센트가_전체_view_이벤트의_30퍼센트_이상을_점유한다() {
+ SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+
+ seeder.seed(spec);
+
+ // Hot = 상위 0.1% = product_id 1~10
+ Long hotEvents = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics WHERE product_id <= 10",
+ Long.class);
+ Long totalEvents = jdbcTemplate.queryForObject(
+ "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics",
+ Long.class);
+
+ double hotShare = hotEvents.doubleValue() / totalEvents;
+ assertThat(hotShare)
+ .as("Hot tier share = " + hotShare)
+ .isGreaterThanOrEqualTo(0.30);
+ }
}
- @DisplayName("시드 결과: Hot tier (상위 0.1%) 가 전체 view 이벤트의 30% 이상을 점유한다 (Zipf head)")
- @Test
- void hotTierDominatesEventVolume() {
- SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L);
- BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
-
- seeder.seed(spec);
-
- // Hot = 상위 0.1% = product_id 1~10
- Long hotEvents = jdbcTemplate.queryForObject(
- "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics WHERE product_id <= 10",
- Long.class);
- Long totalEvents = jdbcTemplate.queryForObject(
- "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics",
- Long.class);
-
- double hotShare = hotEvents.doubleValue() / totalEvents;
- assertThat(hotShare)
- .as("Hot tier share = " + hotShare)
- .isGreaterThanOrEqualTo(0.30);
- }
+ @Nested
+ class 결정성 {
- @DisplayName("시드는 결정적이다 — 같은 seed 로 두 번 돌리면 row 수가 동일")
- @Test
- void seedIsDeterministic() {
- BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+ @Test
+ void 같은_seed_로_두번_돌리면_row_수가_동일하다() {
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
- BaselineSeeder.SeedReport first = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
- databaseCleanUp.truncateAllTables();
- BaselineSeeder.SeedReport second = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
+ BaselineSeeder.SeedReport first = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
+ databaseCleanUp.truncateAllTables();
+ BaselineSeeder.SeedReport second = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L));
- assertAll(
- () -> assertThat(second.viewRowsInserted()).isEqualTo(first.viewRowsInserted()),
- () -> assertThat(second.likeRowsInserted()).isEqualTo(first.likeRowsInserted()),
- () -> assertThat(second.orderRowsInserted()).isEqualTo(first.orderRowsInserted())
- );
+ assertAll(
+ () -> assertThat(second.viewRowsInserted()).isEqualTo(first.viewRowsInserted()),
+ () -> assertThat(second.likeRowsInserted()).isEqualTo(first.likeRowsInserted()),
+ () -> assertThat(second.orderRowsInserted()).isEqualTo(first.orderRowsInserted())
+ );
+ }
}
- @DisplayName("view : like : order = 10 : 1 : 0.1 비율로 시드된다")
- @Test
- void seedRatiosBetweenMetrics() {
- SeedSpec spec = new SeedSpec(1_000, ANCHOR, 7, 42L);
- BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
+ @Nested
+ class 메트릭_비율 {
+
+ @Test
+ void view_like_order_가_10_1_0_1_비율로_시드된다() {
+ SeedSpec spec = new SeedSpec(1_000, ANCHOR, 7, 42L);
+ BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate);
- seeder.seed(spec);
+ seeder.seed(spec);
- long viewSum = sumColumn("product_view_metrics", "view_count");
- long likeSum = sumColumn("product_like_metrics", "like_count");
- long orderSum = sumColumn("product_order_metrics", "quantity");
+ long viewSum = sumColumn("product_view_metrics", "view_count");
+ long likeSum = sumColumn("product_like_metrics", "like_count");
+ long orderSum = sumColumn("product_order_metrics", "quantity");
- // view 와 like 비율이 대략 10:1 부근 (정수 절단으로 일부 손실 허용)
- assertAll(
- () -> assertThat(likeSum).isLessThan(viewSum),
- () -> assertThat(orderSum).isLessThan(likeSum),
- () -> assertThat((double) viewSum / likeSum).isBetween(8.0, 12.0)
- );
+ // view 와 like 비율이 대략 10:1 부근 (정수 절단으로 일부 손실 허용)
+ assertAll(
+ () -> assertThat(likeSum).isLessThan(viewSum),
+ () -> assertThat(orderSum).isLessThan(likeSum),
+ () -> assertThat((double) viewSum / likeSum).isBetween(8.0, 12.0)
+ );
+ }
}
private long sumColumn(String table, String column) {
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
index a88e8e65b4..6617085a31 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java
@@ -12,7 +12,9 @@
import com.loopers.utils.RedisCleanUp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
@@ -49,6 +51,7 @@
@SpringBatchTest
@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class RollingRankingJobBenchmark {
private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14);
@@ -72,30 +75,34 @@ void tearDown() {
redisCleanUp.truncateAll();
}
- @DisplayName("[Benchmark] S단계 (1,000 product) 실행 시간")
- @Test
- void benchmark_small() throws Exception {
- runBenchmark("S", SeedSpec.small(ANCHOR));
- }
+ @Nested
+ class 선형성_측정 {
- @DisplayName("[Benchmark] M단계 (5,000 product) 실행 시간")
- @Test
- void benchmark_medium() throws Exception {
- runBenchmark("M", SeedSpec.medium(ANCHOR));
- }
+ @Test
+ void S단계_1000_product_실행_시간() throws Exception {
+ runBenchmark("S", SeedSpec.small(ANCHOR));
+ }
- @DisplayName("[Benchmark] L단계 (20,000 product) 실행 시간")
- @Test
- void benchmark_large() throws Exception {
- runBenchmark("L", SeedSpec.large(ANCHOR));
+ @Test
+ void M단계_5000_product_실행_시간() throws Exception {
+ runBenchmark("M", SeedSpec.medium(ANCHOR));
+ }
+
+ @Test
+ void L단계_20000_product_실행_시간() throws Exception {
+ runBenchmark("L", SeedSpec.large(ANCHOR));
+ }
}
- @DisplayName("[Benchmark] XL단계 - 스파이크 시뮬레이션 (활동 product 5배)")
- @Test
- void benchmark_xl_spike() throws Exception {
- // L 의 5배 — Hot/Warm 의 일일 이벤트가 그만큼 폭증한 worst-case
- SeedSpec spike = new SeedSpec(100_000, ANCHOR, 30, 42L);
- runBenchmark("XL_SPIKE", spike);
+ @Nested
+ class 스파이크_측정 {
+
+ @Test
+ void XL단계_스파이크_시뮬레이션_활동_product_5배() throws Exception {
+ // L 의 5배 — Hot/Warm 의 일일 이벤트가 그만큼 폭증한 worst-case
+ SeedSpec spike = new SeedSpec(100_000, ANCHOR, 30, 42L);
+ runBenchmark("XL_SPIKE", spike);
+ }
}
private void runBenchmark(String label, SeedSpec spec) throws Exception {
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
index ab2fc18d7f..6bf3b84ca9 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java
@@ -2,7 +2,9 @@
import com.loopers.domain.ranking.weight.WeightConfig;
import com.loopers.domain.ranking.weight.WeightConfigRepository;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParametersBuilder;
@@ -14,6 +16,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class RankingJobParametersListenerTest {
private final WeightConfigRepository stubRepo = new WeightConfigRepository() {
@@ -25,61 +28,66 @@ class RankingJobParametersListenerTest {
private final RankingJobParametersListener listener = new RankingJobParametersListener(stubRepo);
- @DisplayName("beforeJob 은 anchorDate 파라미터로부터 ExecutionContext 에 경계 값 5개를 주입한다.")
- @Test
- void populatesExecutionContextFromAnchorDate() {
- JobExecution execution = MetaDataInstanceFactory.createJobExecution(
- "rollingRankingJob", 1L, 1L,
- new JobParametersBuilder()
- .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
- .toJobParameters()
- );
-
- listener.beforeJob(execution);
-
- var ctx = execution.getExecutionContext();
- assertAll(
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260414"),
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2026-04-08T00:00"),
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_END)).isEqualTo("2026-04-15T00:00"),
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_START)).isEqualTo("2026-03-16T00:00"),
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_END)).isEqualTo("2026-04-15T00:00")
- );
- }
+ @Nested
+ class ExecutionContext_주입 {
+
+ @Test
+ void anchorDate_파라미터로부터_경계_값_5개를_주입한다() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 1L,
+ new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
+ .toJobParameters()
+ );
+
+ listener.beforeJob(execution);
+
+ var ctx = execution.getExecutionContext();
+ assertAll(
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260414"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2026-04-08T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_END)).isEqualTo("2026-04-15T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_START)).isEqualTo("2026-03-16T00:00"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_END)).isEqualTo("2026-04-15T00:00")
+ );
+ }
- @DisplayName("anchorDate 파라미터가 없으면 예외를 던진다 (Bounded 위반 차단).")
- @Test
- void rejectsMissingAnchorDate() {
- JobExecution execution = MetaDataInstanceFactory.createJobExecution(
- "rollingRankingJob", 1L, 2L,
- new JobParametersBuilder().toJobParameters()
- );
+ @Test
+ void anchorDate_파라미터가_없으면_예외를_던진다() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 2L,
+ new JobParametersBuilder().toJobParameters()
+ );
- assertThatThrownBy(() -> listener.beforeJob(execution))
- .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> listener.beforeJob(execution))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
}
- @DisplayName("재시작으로 ExecutionContext 에 이미 값이 있으면 덮어쓰지 않는다 (Bounded 유지).")
- @Test
- void doesNotOverwriteOnRestart() {
- JobExecution execution = MetaDataInstanceFactory.createJobExecution(
- "rollingRankingJob", 1L, 3L,
- new JobParametersBuilder()
- .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
- .toJobParameters()
- );
- // 첫 실행이 남긴 값을 모방
- execution.getExecutionContext().putString(
- RankingJobParametersListener.CTX_ANCHOR_DATE_KEY, "20260101");
- execution.getExecutionContext().putString(
- RankingJobParametersListener.CTX_LAST_7D_START, "2025-12-26T00:00");
-
- listener.beforeJob(execution);
-
- var ctx = execution.getExecutionContext();
- assertAll(
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260101"),
- () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2025-12-26T00:00")
- );
+ @Nested
+ class 재시작 {
+
+ @Test
+ void ExecutionContext_에_이미_값이_있으면_덮어쓰지_않는다() {
+ JobExecution execution = MetaDataInstanceFactory.createJobExecution(
+ "rollingRankingJob", 1L, 3L,
+ new JobParametersBuilder()
+ .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414")
+ .toJobParameters()
+ );
+ // 첫 실행이 남긴 값을 모방
+ execution.getExecutionContext().putString(
+ RankingJobParametersListener.CTX_ANCHOR_DATE_KEY, "20260101");
+ execution.getExecutionContext().putString(
+ RankingJobParametersListener.CTX_LAST_7D_START, "2025-12-26T00:00");
+
+ listener.beforeJob(execution);
+
+ var ctx = execution.getExecutionContext();
+ assertAll(
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260101"),
+ () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2025-12-26T00:00")
+ );
+ }
}
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
index 4c9255d512..932ad34821 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java
@@ -1,6 +1,8 @@
package com.loopers.batch.job.ranking.param;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
@@ -10,84 +12,87 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertAll;
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class RollingWindowResolverTest {
- @DisplayName("anchorDate 로부터 LAST_7D / LAST_30D 경계를 결정적으로 계산한다.")
- @Test
- void resolvesBoundariesDeterministically() {
- RollingWindow window = RollingWindowResolver.resolve("20260414");
-
- assertAll(
- () -> assertThat(window.anchorDate()).isEqualTo(LocalDate.of(2026, 4, 14)),
- () -> assertThat(window.anchorDateKey()).isEqualTo("20260414"),
- () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2026, 4, 8, 0, 0)),
- () -> assertThat(window.last7dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0)),
- () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2026, 3, 16, 0, 0)),
- () -> assertThat(window.last30dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0))
- );
- }
+ @Nested
+ class 경계_계산 {
- @DisplayName("LAST_7D 구간은 anchor 포함 7일, LAST_30D 구간은 anchor 포함 30일이다.")
- @Test
- void windowSpansAreCorrect() {
- RollingWindow window = RollingWindowResolver.resolve("20260414");
+ @Test
+ void anchorDate_로부터_7D_30D_경계를_결정적으로_계산한다() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
- long last7dDays = java.time.Duration.between(window.last7dStart(), window.last7dEnd()).toDays();
- long last30dDays = java.time.Duration.between(window.last30dStart(), window.last30dEnd()).toDays();
+ assertAll(
+ () -> assertThat(window.anchorDate()).isEqualTo(LocalDate.of(2026, 4, 14)),
+ () -> assertThat(window.anchorDateKey()).isEqualTo("20260414"),
+ () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2026, 4, 8, 0, 0)),
+ () -> assertThat(window.last7dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0)),
+ () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2026, 3, 16, 0, 0)),
+ () -> assertThat(window.last30dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0))
+ );
+ }
- assertAll(
- () -> assertThat(last7dDays).isEqualTo(7),
- () -> assertThat(last30dDays).isEqualTo(30),
- () -> assertThat(window.last7dEnd()).isEqualTo(window.last30dEnd())
- );
- }
+ @Test
+ void LAST_7D_는_7일_LAST_30D_는_30일_구간이다() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
- @DisplayName("LAST_7D / LAST_30D 상한은 anchor + 1일 00:00 으로 '오늘은 제외' 된다.")
- @Test
- void endBoundaryExcludesToday() {
- RollingWindow window = RollingWindowResolver.resolve("20260414");
+ long last7dDays = java.time.Duration.between(window.last7dStart(), window.last7dEnd()).toDays();
+ long last30dDays = java.time.Duration.between(window.last30dStart(), window.last30dEnd()).toDays();
- LocalDateTime today0am = LocalDate.of(2026, 4, 15).atStartOfDay();
- assertAll(
- () -> assertThat(window.last7dEnd()).isEqualTo(today0am),
- () -> assertThat(window.last30dEnd()).isEqualTo(today0am)
- );
- }
+ assertAll(
+ () -> assertThat(last7dDays).isEqualTo(7),
+ () -> assertThat(last30dDays).isEqualTo(30),
+ () -> assertThat(window.last7dEnd()).isEqualTo(window.last30dEnd())
+ );
+ }
- @DisplayName("월 경계를 걸쳐도 안전하게 계산된다 (음수 일자 없음).")
- @Test
- void handlesMonthCrossing() {
- RollingWindow window = RollingWindowResolver.resolve("20260102");
+ @Test
+ void 상한은_anchor_다음날_00시로_오늘은_제외된다() {
+ RollingWindow window = RollingWindowResolver.resolve("20260414");
- assertAll(
- () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2025, 12, 27, 0, 0)),
- () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2025, 12, 4, 0, 0))
- );
- }
+ LocalDateTime today0am = LocalDate.of(2026, 4, 15).atStartOfDay();
+ assertAll(
+ () -> assertThat(window.last7dEnd()).isEqualTo(today0am),
+ () -> assertThat(window.last30dEnd()).isEqualTo(today0am)
+ );
+ }
- @DisplayName("anchorDate 가 비어 있으면 예외를 던진다.")
- @Test
- void rejectsBlankAnchor() {
- assertAll(
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(null))
- .isInstanceOf(IllegalArgumentException.class),
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(""))
- .isInstanceOf(IllegalArgumentException.class),
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(" "))
- .isInstanceOf(IllegalArgumentException.class)
- );
+ @Test
+ void 월_경계를_걸쳐도_안전하게_계산된다() {
+ RollingWindow window = RollingWindowResolver.resolve("20260102");
+
+ assertAll(
+ () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2025, 12, 27, 0, 0)),
+ () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2025, 12, 4, 0, 0))
+ );
+ }
}
- @DisplayName("anchorDate 포맷이 yyyyMMdd 가 아니면 예외를 던진다.")
- @Test
- void rejectsMalformedAnchor() {
- assertAll(
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("2026-04-14"))
- .isInstanceOf(IllegalArgumentException.class),
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("20260230"))
- .isInstanceOf(IllegalArgumentException.class),
- () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("abcdefgh"))
- .isInstanceOf(IllegalArgumentException.class)
- );
+ @Nested
+ class 입력_검증 {
+
+ @Test
+ void anchorDate_가_비어있으면_예외를_던진다() {
+ assertAll(
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(null))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(""))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ );
+ }
+
+ @Test
+ void anchorDate_포맷이_yyyyMMdd_가_아니면_예외를_던진다() {
+ assertAll(
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("2026-04-14"))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("20260230"))
+ .isInstanceOf(IllegalArgumentException.class),
+ () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("abcdefgh"))
+ .isInstanceOf(IllegalArgumentException.class)
+ );
+ }
}
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
index f847f43709..910303a888 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
@@ -9,7 +9,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -34,6 +36,7 @@
@SpringBatchTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class PurgeMvStepIntegrationTest {
private static final LocalDate TARGET_ANCHOR = LocalDate.of(2026, 4, 14);
@@ -52,49 +55,54 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("타겟 anchorDate 의 MV row 만 양쪽 테이블에서 삭제되고 다른 anchor 는 유지된다.")
- @Test
- void purgesOnlyTargetAnchorInBothMvTables() throws Exception {
- last7dRepository.save(mv7d(TARGET_ANCHOR, 1L, 1));
- last7dRepository.save(mv7d(OTHER_ANCHOR, 2L, 1));
- last30dRepository.save(mv30d(TARGET_ANCHOR, 1L, 1));
- last30dRepository.save(mv30d(OTHER_ANCHOR, 2L, 1));
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
- () -> assertThat(last30dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
- () -> assertThat(last7dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne(),
- () -> assertThat(last30dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne()
- );
+ @Nested
+ class MV_purge {
+
+ @Test
+ void 타겟_anchorDate_의_MV_row_만_양쪽_테이블에서_삭제되고_다른_anchor_는_유지된다() throws Exception {
+ last7dRepository.save(mv7d(TARGET_ANCHOR, 1L, 1));
+ last7dRepository.save(mv7d(OTHER_ANCHOR, 2L, 1));
+ last30dRepository.save(mv30d(TARGET_ANCHOR, 1L, 1));
+ last30dRepository.save(mv30d(OTHER_ANCHOR, 2L, 1));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
+ () -> assertThat(last30dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
+ () -> assertThat(last7dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne(),
+ () -> assertThat(last30dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne()
+ );
+ }
+
+ @Test
+ void 동일_anchor_같은_상품이_여러_weight_group_에_있으면_전부_삭제된다() throws Exception {
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "control", 1L, 1));
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_a", 1L, 1));
+ last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_b", 1L, 1));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero()
+ );
+ }
}
- @DisplayName("MV 에 동일 anchor/같은 상품이 여러 weight_group 에 있으면 그룹 구분 없이 전부 삭제된다.")
- @Test
- void purgesAllWeightGroupsForAnchor() throws Exception {
- last7dRepository.save(mv7d(TARGET_ANCHOR, "control", 1L, 1));
- last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_a", 1L, 1));
- last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_b", 1L, 1));
+ @Nested
+ class 빈_MV {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
+ @Test
+ void MV_가_비어_있어도_Job_은_성공한다() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero()
- );
- }
-
- @DisplayName("MV 가 비어 있어도 Job 은 성공한다 (첫 실행 시나리오).")
- @Test
- void succeedsOnEmptyMv() throws Exception {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ }
}
// -- helpers --
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
index 7edee7d53e..2dd5b406d8 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
@@ -7,7 +7,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -39,6 +41,7 @@
@SpringBatchTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class ScoreAggregationStepIntegrationTest {
private static final String ANCHOR = "20260414";
@@ -55,68 +58,77 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("활성 weight_group 별로 2차 staging 에 row 가 fan-out 되고 score 가 공식대로 계산된다.")
- @Test
- void fansOutPerWeightGroupWithCorrectScore() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
-
- saveView(1L, IN_7D, 100);
- saveLike(1L, IN_7D, 50);
- saveOrder(1L, IN_7D, 999);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- double expectedScore = ScoreFormula.compute(
- 100, 50, 999,
- new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)
- );
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // LAST_7D + LAST_30D × control 1 group = 2 rows
- () -> assertThat(scoredCount(ANCHOR)).isEqualTo(2L),
- () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9)),
- () -> assertThat(scoreOf("LAST_30D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9))
- );
+ @Nested
+ class score_계산 {
+
+ @Test
+ void 활성_weight_group_별로_2차_staging_에_fan_out_되고_score_가_공식대로_계산된다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 50);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ double expectedScore = ScoreFormula.compute(
+ 100, 50, 999,
+ new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)
+ );
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // LAST_7D + LAST_30D × control 1 group = 2 rows
+ () -> assertThat(scoredCount(ANCHOR)).isEqualTo(2L),
+ () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9)),
+ () -> assertThat(scoreOf("LAST_30D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9))
+ );
+ }
}
- @DisplayName("여러 weight_group 이 활성화되어 있으면 각 그룹별로 독립적인 score 가 저장된다.")
- @Test
- void multipleWeightGroupsProduceIndependentScores() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
- weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
- weightConfigRepository.save(new WeightConfig("inactive", 0.3, 0.3, 0.4, 0, false)); // 제외
-
- saveView(1L, IN_7D, 100);
- saveLike(1L, IN_7D, 100);
- saveOrder(1L, IN_7D, 999);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // (LAST_7D + LAST_30D) × (control + experiment_a) = 4 rows, inactive 제외
- () -> assertThat(scoredCount(ANCHOR)).isEqualTo(4L),
- () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L))
- .isNotEqualTo(scoreOf("LAST_7D", ANCHOR, "experiment_a", 1L)),
- () -> assertThat(existsScored(ANCHOR, "inactive")).isFalse()
- );
+ @Nested
+ class 다중_weight_group {
+
+ @Test
+ void 여러_weight_group_이_활성화되면_각_그룹별로_독립적인_score_가_저장된다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true));
+ weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true));
+ weightConfigRepository.save(new WeightConfig("inactive", 0.3, 0.3, 0.4, 0, false)); // 제외
+
+ saveView(1L, IN_7D, 100);
+ saveLike(1L, IN_7D, 100);
+ saveOrder(1L, IN_7D, 999);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ // (LAST_7D + LAST_30D) × (control + experiment_a) = 4 rows, inactive 제외
+ () -> assertThat(scoredCount(ANCHOR)).isEqualTo(4L),
+ () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L))
+ .isNotEqualTo(scoreOf("LAST_7D", ANCHOR, "experiment_a", 1L)),
+ () -> assertThat(existsScored(ANCHOR, "inactive")).isFalse()
+ );
+ }
}
- @DisplayName("원천이 비어 있으면 1차/2차 staging 모두 비어 있고 Job 은 성공한다.")
- @Test
- void emptySourceProducesEmptyScored() throws Exception {
- weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
+ @Nested
+ class 빈_원천 {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+ @Test
+ void 원천이_비어_있으면_1차_2차_staging_모두_비어_있고_Job_은_성공한다() throws Exception {
+ weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true));
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(scoredCount(ANCHOR)).isZero()
- );
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(scoredCount(ANCHOR)).isZero()
+ );
+ }
}
// -- helpers --
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
index 39b7261212..3cad8c0c62 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java
@@ -1,54 +1,61 @@
package com.loopers.batch.job.ranking.step.score;
import com.loopers.domain.ranking.weight.WeightConfig;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.offset;
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class ScoreFormulaTest {
private static final WeightConfig DEFAULT = new WeightConfig("control", 0.1, 0.2, 0.7, 100, true);
- @DisplayName("score = w_view × view + w_like × like + w_order × log10(sales + 1)")
- @Test
- void computesWeightedSum() {
- // w_view=0.1, w_like=0.2, w_order=0.7
- // view=100, like=50, sales=999
- // → 0.1*100 + 0.2*50 + 0.7*log10(1000) = 10 + 10 + 2.1 = 22.1
- double score = ScoreFormula.compute(100, 50, 999, DEFAULT);
+ @Nested
+ class 점수_계산 {
- assertThat(score).isCloseTo(22.1, offset(1e-9));
- }
+ @Test
+ void 가중합_공식대로_score_를_계산한다() {
+ // w_view=0.1, w_like=0.2, w_order=0.7
+ // view=100, like=50, sales=999
+ // → 0.1*100 + 0.2*50 + 0.7*log10(1000) = 10 + 10 + 2.1 = 22.1
+ double score = ScoreFormula.compute(100, 50, 999, DEFAULT);
- @DisplayName("sales=0 이어도 log10(1)=0 으로 안전하게 계산된다 (log10(0) 회피).")
- @Test
- void handlesZeroSalesSafely() {
- double score = ScoreFormula.compute(0, 0, 0, DEFAULT);
+ assertThat(score).isCloseTo(22.1, offset(1e-9));
+ }
- assertThat(score).isEqualTo(0.0);
- }
+ @Test
+ void sales_가_0이어도_log10_1_로_안전하게_계산된다() {
+ double score = ScoreFormula.compute(0, 0, 0, DEFAULT);
- @DisplayName("view/like 는 선형, sales 는 log 스케일이라 큰 금액도 다른 지표를 압도하지 않는다.")
- @Test
- void salesIsLogNormalized() {
- // sales 1_000_000 → log10(1_000_001) ≈ 6
- // 0.7 * 6 ≈ 4.2 (view 42 나 like 21 과 동급)
- double scoreHighSales = ScoreFormula.compute(0, 0, 1_000_000, DEFAULT);
+ assertThat(score).isEqualTo(0.0);
+ }
- assertThat(scoreHighSales).isCloseTo(0.7 * 6, offset(0.001));
+ @Test
+ void sales_는_log_스케일이라_큰_금액도_다른_지표를_압도하지_않는다() {
+ // sales 1_000_000 → log10(1_000_001) ≈ 6
+ // 0.7 * 6 ≈ 4.2 (view 42 나 like 21 과 동급)
+ double scoreHighSales = ScoreFormula.compute(0, 0, 1_000_000, DEFAULT);
+
+ assertThat(scoreHighSales).isCloseTo(0.7 * 6, offset(0.001));
+ }
}
- @DisplayName("weight 가 다른 두 config 는 같은 입력에 다른 score 를 만든다.")
- @Test
- void weightDrivesDivergentScores() {
- WeightConfig viewHeavy = new WeightConfig("a", 0.8, 0.1, 0.1, 50, true);
- WeightConfig orderHeavy = new WeightConfig("b", 0.1, 0.1, 0.8, 50, true);
+ @Nested
+ class weight_분기 {
+
+ @Test
+ void weight_가_다른_두_config_는_같은_입력에_다른_score_를_만든다() {
+ WeightConfig viewHeavy = new WeightConfig("a", 0.8, 0.1, 0.1, 50, true);
+ WeightConfig orderHeavy = new WeightConfig("b", 0.1, 0.1, 0.8, 50, true);
- double s1 = ScoreFormula.compute(100, 100, 100, viewHeavy);
- double s2 = ScoreFormula.compute(100, 100, 100, orderHeavy);
+ double s1 = ScoreFormula.compute(100, 100, 100, viewHeavy);
+ double s2 = ScoreFormula.compute(100, 100, 100, orderHeavy);
- assertThat(s1).isNotEqualTo(s2);
+ assertThat(s1).isNotEqualTo(s2);
+ }
}
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
index c4e07eff00..539a172091 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java
@@ -6,7 +6,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -37,6 +39,7 @@
@SpringBatchTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StageMetricsPipelineIntegrationTest {
private static final String ANCHOR = "20260414";
@@ -54,74 +57,78 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("3 메트릭(View/Like/Order) 이 모두 있는 상품은 staging row 하나에 세 컬럼이 모두 채워진다.")
- @Test
- void mergesThreeMetricsIntoOneRow() throws Exception {
- saveView(1L, IN_7D, 10);
- saveView(1L, IN_30D_ONLY, 5);
- saveLike(1L, IN_7D, 2);
- saveLike(1L, IN_30D_ONLY, 3);
- saveOrder(1L, IN_7D, 1000);
- saveOrder(1L, IN_30D_ONLY, 2000);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(row("LAST_7D", ANCHOR, 1L)).containsExactly(10L, 2L, 1000L),
- () -> assertThat(row("LAST_30D", ANCHOR, 1L)).containsExactly(15L, 5L, 3000L)
- );
- }
-
- @DisplayName("Like 만 있는 상품은 Step 2 의 INSERT 로 row 가 새로 생성되고 view/sales 는 0 이다.")
- @Test
- void likeOnlyProductIsInsertedByStep2() throws Exception {
- saveLike(2L, IN_7D, 4);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(row("LAST_7D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L),
- () -> assertThat(row("LAST_30D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L)
- );
+ @Nested
+ class 메트릭_병합 {
+
+ @Test
+ void 세_메트릭이_모두_있는_상품은_staging_row_하나에_세_컬럼이_모두_채워진다() throws Exception {
+ saveView(1L, IN_7D, 10);
+ saveView(1L, IN_30D_ONLY, 5);
+ saveLike(1L, IN_7D, 2);
+ saveLike(1L, IN_30D_ONLY, 3);
+ saveOrder(1L, IN_7D, 1000);
+ saveOrder(1L, IN_30D_ONLY, 2000);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 1L)).containsExactly(10L, 2L, 1000L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 1L)).containsExactly(15L, 5L, 3000L)
+ );
+ }
+
+ @Test
+ void Like_만_있는_상품은_Step2_의_INSERT_로_row_가_생성되고_view_sales_는_0이다() throws Exception {
+ saveLike(2L, IN_7D, 4);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L)
+ );
+ }
+
+ @Test
+ void Order_만_있는_상품도_Step3_의_INSERT_로_row_가_생성된다() throws Exception {
+ saveOrder(3L, IN_30D_ONLY, 5000);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 3L)).containsExactly(0L, 0L, 0L),
+ () -> assertThat(row("LAST_30D", ANCHOR, 3L)).containsExactly(0L, 0L, 5000L)
+ );
+ }
}
- @DisplayName("Order 만 있는 상품도 Step 3 의 INSERT 로 row 가 생성된다.")
- @Test
- void orderOnlyProductIsInsertedByStep3() throws Exception {
- saveOrder(3L, IN_30D_ONLY, 5000);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(row("LAST_7D", ANCHOR, 3L)).containsExactly(0L, 0L, 0L),
- () -> assertThat(row("LAST_30D", ANCHOR, 3L)).containsExactly(0L, 0L, 5000L)
- );
- }
-
- @DisplayName("메트릭마다 다른 상품 집합이 있어도 각각 독립적으로 적재된다.")
- @Test
- void independentProductsPerMetric() throws Exception {
- saveView(10L, IN_7D, 1);
- saveLike(20L, IN_7D, 2);
- saveOrder(30L, IN_7D, 3);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(row("LAST_7D", ANCHOR, 10L)).containsExactly(1L, 0L, 0L),
- () -> assertThat(row("LAST_7D", ANCHOR, 20L)).containsExactly(0L, 2L, 0L),
- () -> assertThat(row("LAST_7D", ANCHOR, 30L)).containsExactly(0L, 0L, 3L),
- // product 당 LAST_7D + LAST_30D → 3 × 2 = 6
- () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(6L)
- );
+ @Nested
+ class 독립_적재 {
+
+ @Test
+ void 메트릭마다_다른_상품_집합이_있어도_각각_독립적으로_적재된다() throws Exception {
+ saveView(10L, IN_7D, 1);
+ saveLike(20L, IN_7D, 2);
+ saveOrder(30L, IN_7D, 3);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(row("LAST_7D", ANCHOR, 10L)).containsExactly(1L, 0L, 0L),
+ () -> assertThat(row("LAST_7D", ANCHOR, 20L)).containsExactly(0L, 2L, 0L),
+ () -> assertThat(row("LAST_7D", ANCHOR, 30L)).containsExactly(0L, 0L, 3L),
+ // product 당 LAST_7D + LAST_30D → 3 × 2 = 6
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(6L)
+ );
+ }
}
// -- helpers --
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
index 8dffb4c5a1..7365baf966 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java
@@ -6,7 +6,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -32,6 +34,7 @@
@SpringBatchTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StageViewMetricsStepIntegrationTest {
private static final String ANCHOR = "20260414";
@@ -52,61 +55,70 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("anchor 범위 내 bucket 은 집계되고, 범위 밖 (30일 이전·오늘 이후) 은 제외된다.")
- @Test
- void aggregatesOnlyWithinWindow() throws Exception {
- // product 1: 7d 10 + 30d 추가 5 = sum7d 10, sum30d 15
- saveView(1L, IN_7D, 10L);
- saveView(1L, IN_30D_ONLY, 5L);
- // product 2: 30d only
- saveView(2L, IN_30D_ONLY, 7L);
- // 범위 밖 — 집계 제외
- saveView(3L, BEFORE_30D, 100L);
- saveView(3L, ON_TODAY, 100L);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(10L),
- () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(15L),
- () -> assertThat(viewCount("LAST_7D", ANCHOR, 2L)).isEqualTo(0L),
- () -> assertThat(viewCount("LAST_30D", ANCHOR, 2L)).isEqualTo(7L),
- () -> assertThat(productExists(ANCHOR, 3L)).isFalse(),
- () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(4L) // (LAST_7D + LAST_30D) × 2 products
- );
+ @Nested
+ class 윈도우_집계 {
+
+ @Test
+ void anchor_범위_내_bucket_은_집계되고_범위_밖은_제외된다() throws Exception {
+ // product 1: 7d 10 + 30d 추가 5 = sum7d 10, sum30d 15
+ saveView(1L, IN_7D, 10L);
+ saveView(1L, IN_30D_ONLY, 5L);
+ // product 2: 30d only
+ saveView(2L, IN_30D_ONLY, 7L);
+ // 범위 밖 — 집계 제외
+ saveView(3L, BEFORE_30D, 100L);
+ saveView(3L, ON_TODAY, 100L);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(10L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(15L),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 2L)).isEqualTo(0L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 2L)).isEqualTo(7L),
+ () -> assertThat(productExists(ANCHOR, 3L)).isFalse(),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(4L) // (LAST_7D + LAST_30D) × 2 products
+ );
+ }
}
- @DisplayName("같은 anchor 로 Job 을 다시 돌려도 결과가 동일하다 (멱등성).")
- @Test
- void idempotentOnRerun() throws Exception {
- saveView(1L, IN_7D, 3L);
- saveView(1L, IN_7D.plusHours(1), 4L);
-
- jobLauncherTestUtils.setJob(job);
- JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
- JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
-
- assertAll(
- () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(7L),
- () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(7L),
- () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(2L)
- );
+ @Nested
+ class 멱등성 {
+
+ @Test
+ void 같은_anchor_로_두번_돌려도_결과가_동일하다() throws Exception {
+ saveView(1L, IN_7D, 3L);
+ saveView(1L, IN_7D.plusHours(1), 4L);
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(7L),
+ () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(7L),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(2L)
+ );
+ }
}
- @DisplayName("원천이 비어 있어도 Job 은 성공하고 staging 은 비어 있다.")
- @Test
- void emptySourceSucceeds() throws Exception {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+ @Nested
+ class 빈_원천 {
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isZero()
- );
+ @Test
+ void 원천이_비어_있어도_Job_은_성공하고_staging_은_비어_있다() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isZero()
+ );
+ }
}
// -- helpers --
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
index eeaddde706..93a9d53cc2 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java
@@ -1,6 +1,8 @@
package com.loopers.batch.job.ranking.step.stage;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
@@ -11,89 +13,97 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StreamingMetricAggregatorTest {
private static final LocalDateTime LAST_7D_START = LocalDateTime.of(2026, 4, 8, 0, 0);
private static final LocalDateTime LAST_30D_START = LocalDateTime.of(2026, 3, 16, 0, 0);
- @DisplayName("같은 product 의 연속된 row 는 한 AggregatedMetric 으로 합쳐진다.")
- @Test
- void collapseSameProductRowsIntoOneAggregated() throws Exception {
- ListSource source = new ListSource(List.of(
- row(1L, LAST_7D_START, 5), // 7d 포함
- row(1L, LAST_7D_START.plusDays(3), 10), // 7d 포함
- row(2L, LAST_30D_START, 3) // 30d 만
- ));
- StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
-
- AggregatedMetric first = agg.next();
- AggregatedMetric second = agg.next();
- AggregatedMetric end = agg.next();
-
- assertAll(
- () -> assertThat(first.productId()).isEqualTo(1L),
- () -> assertThat(first.sum7d()).isEqualTo(15),
- () -> assertThat(first.sum30d()).isEqualTo(15),
- () -> assertThat(second.productId()).isEqualTo(2L),
- () -> assertThat(second.sum7d()).isEqualTo(0),
- () -> assertThat(second.sum30d()).isEqualTo(3),
- () -> assertThat(end).isNull()
- );
- }
+ @Nested
+ class 집계 {
+
+ @Test
+ void 같은_product_의_연속된_row_는_한_AggregatedMetric_으로_합쳐진다() throws Exception {
+ ListSource source = new ListSource(List.of(
+ row(1L, LAST_7D_START, 5), // 7d 포함
+ row(1L, LAST_7D_START.plusDays(3), 10), // 7d 포함
+ row(2L, LAST_30D_START, 3) // 30d 만
+ ));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ AggregatedMetric first = agg.next();
+ AggregatedMetric second = agg.next();
+ AggregatedMetric end = agg.next();
+
+ assertAll(
+ () -> assertThat(first.productId()).isEqualTo(1L),
+ () -> assertThat(first.sum7d()).isEqualTo(15),
+ () -> assertThat(first.sum30d()).isEqualTo(15),
+ () -> assertThat(second.productId()).isEqualTo(2L),
+ () -> assertThat(second.sum7d()).isEqualTo(0),
+ () -> assertThat(second.sum30d()).isEqualTo(3),
+ () -> assertThat(end).isNull()
+ );
+ }
- @DisplayName("bucket_time 이 last7dStart 보다 앞이면 sum7d 에는 포함되지 않고 sum30d 에만 포함된다.")
- @Test
- void boundaryExcludesPre7dFromSum7d() throws Exception {
- ListSource source = new ListSource(List.of(
- row(1L, LAST_30D_START, 7), // 30d O, 7d X
- row(1L, LAST_7D_START.minusSeconds(1), 2), // 30d O, 7d X (경계 직전)
- row(1L, LAST_7D_START, 3) // 30d O, 7d O (경계 포함)
- ));
- StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
-
- AggregatedMetric result = agg.next();
-
- assertAll(
- () -> assertThat(result.sum30d()).isEqualTo(12),
- () -> assertThat(result.sum7d()).isEqualTo(3)
- );
+ @Test
+ void bucket_time_이_last7dStart_보다_앞이면_sum7d_에는_포함되지_않고_sum30d_에만_포함된다() throws Exception {
+ ListSource source = new ListSource(List.of(
+ row(1L, LAST_30D_START, 7), // 30d O, 7d X
+ row(1L, LAST_7D_START.minusSeconds(1), 2), // 30d O, 7d X (경계 직전)
+ row(1L, LAST_7D_START, 3) // 30d O, 7d O (경계 포함)
+ ));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+
+ AggregatedMetric result = agg.next();
+
+ assertAll(
+ () -> assertThat(result.sum30d()).isEqualTo(12),
+ () -> assertThat(result.sum7d()).isEqualTo(3)
+ );
+ }
}
- @DisplayName("소스가 비어 있으면 첫 호출부터 null 을 반환한다.")
- @Test
- void emptySourceReturnsNullImmediately() throws Exception {
- StreamingMetricAggregator agg = new StreamingMetricAggregator(new ListSource(List.of()), LAST_7D_START);
+ @Nested
+ class 빈_소스 {
- assertThat(agg.next()).isNull();
- }
+ @Test
+ void 소스가_비어_있으면_첫_호출부터_null_을_반환한다() throws Exception {
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(new ListSource(List.of()), LAST_7D_START);
+
+ assertThat(agg.next()).isNull();
+ }
+
+ @Test
+ void 한_번_exhausted_되면_이후_호출도_항상_null_을_반환한다() throws Exception {
+ ListSource source = new ListSource(List.of(row(1L, LAST_7D_START, 5)));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
- @DisplayName("한 번 exhausted 되면 이후 호출도 항상 null 을 반환한다.")
- @Test
- void stablyReturnsNullAfterExhaustion() throws Exception {
- ListSource source = new ListSource(List.of(row(1L, LAST_7D_START, 5)));
- StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
-
- assertAll(
- () -> assertThat(agg.next()).isNotNull(),
- () -> assertThat(agg.next()).isNull(),
- () -> assertThat(agg.next()).isNull()
- );
+ assertAll(
+ () -> assertThat(agg.next()).isNotNull(),
+ () -> assertThat(agg.next()).isNull(),
+ () -> assertThat(agg.next()).isNull()
+ );
+ }
}
- @DisplayName("한 상품이 많은 row (예: 8,640 개) 를 가져도 O(1) 메모리로 처리된다.")
- @Test
- void handlesLongChainWithConstantMemory() throws Exception {
- int chainLength = 8_640;
- ListSource source = new ListSource(generateChain(1L, chainLength));
- StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
+ @Nested
+ class 대량_처리 {
- AggregatedMetric result = agg.next();
+ @Test
+ void 한_상품이_많은_row_를_가져도_O1_메모리로_처리된다() throws Exception {
+ int chainLength = 8_640;
+ ListSource source = new ListSource(generateChain(1L, chainLength));
+ StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START);
- assertAll(
- () -> assertThat(result.productId()).isEqualTo(1L),
- () -> assertThat(result.sum30d()).isEqualTo(chainLength),
- () -> assertThat(agg.next()).isNull()
- );
+ AggregatedMetric result = agg.next();
+
+ assertAll(
+ () -> assertThat(result.productId()).isEqualTo(1L),
+ () -> assertThat(result.sum30d()).isEqualTo(chainLength),
+ () -> assertThat(agg.next()).isNull()
+ );
+ }
}
private static RawMetricRow row(long productId, LocalDateTime bucketTime, long count) {
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
index 699977908f..f2f1d74451 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java
@@ -9,7 +9,9 @@
import com.loopers.testcontainers.MySqlTestContainersConfig;
import com.loopers.utils.DatabaseCleanUp;
import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DisplayNameGeneration;
+import org.junit.jupiter.api.DisplayNameGenerator;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
@@ -31,6 +33,7 @@
@SpringBatchTest
@Import(MySqlTestContainersConfig.class)
@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
+@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class TruncateStagingStepIntegrationTest {
@Autowired private JobLauncherTestUtils jobLauncherTestUtils;
@@ -44,71 +47,79 @@ void tearDown() {
databaseCleanUp.truncateAllTables();
}
- @DisplayName("anchorDateKey 에 해당하는 두 스테이징 테이블의 row 만 삭제된다.")
- @Test
- void deletesOnlyTargetAnchor() throws Exception {
- String targetAnchor = "20260414";
- String otherAnchor = "20260101";
- aggregationRepository.save(new StagingRankingAggregation("LAST_7D", targetAnchor, 1L, 10, 0, 0));
- aggregationRepository.save(new StagingRankingAggregation("LAST_30D", targetAnchor, 2L, 20, 0, 0));
- aggregationRepository.save(new StagingRankingAggregation("LAST_7D", otherAnchor, 3L, 30, 0, 0));
- scoredRepository.save(new StagingRankingScored("LAST_7D", targetAnchor, "control", 1L, 10, 0, 0, 1.0));
- scoredRepository.save(new StagingRankingScored("LAST_30D", otherAnchor, "control", 3L, 30, 0, 0, 3.0));
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(targetAnchor));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(aggregationRepository.countByPeriodKey(targetAnchor)).isZero(),
- () -> assertThat(scoredRepository.countByPeriodKey(targetAnchor)).isZero(),
- () -> assertThat(aggregationRepository.countByPeriodKey(otherAnchor)).isOne(),
- () -> assertThat(scoredRepository.countByPeriodKey(otherAnchor)).isOne()
- );
+ @Nested
+ class 대상_삭제 {
+
+ @Test
+ void anchorDateKey_에_해당하는_두_스테이징_테이블의_row_만_삭제된다() throws Exception {
+ String targetAnchor = "20260414";
+ String otherAnchor = "20260101";
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", targetAnchor, 1L, 10, 0, 0));
+ aggregationRepository.save(new StagingRankingAggregation("LAST_30D", targetAnchor, 2L, 20, 0, 0));
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", otherAnchor, 3L, 30, 0, 0));
+ scoredRepository.save(new StagingRankingScored("LAST_7D", targetAnchor, "control", 1L, 10, 0, 0, 1.0));
+ scoredRepository.save(new StagingRankingScored("LAST_30D", otherAnchor, "control", 3L, 30, 0, 0, 3.0));
+
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(targetAnchor));
+
+ assertAll(
+ () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(targetAnchor)).isZero(),
+ () -> assertThat(scoredRepository.countByPeriodKey(targetAnchor)).isZero(),
+ () -> assertThat(aggregationRepository.countByPeriodKey(otherAnchor)).isOne(),
+ () -> assertThat(scoredRepository.countByPeriodKey(otherAnchor)).isOne()
+ );
+ }
}
- @DisplayName("비어있는 스테이징에 실행해도 멱등하게 성공한다 (첫 실행 시나리오).")
- @Test
- void succeedsOnEmptyStaging() throws Exception {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf("20260414"));
+ @Nested
+ class 멱등성 {
- assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
- }
+ @Test
+ void 비어있는_스테이징에_실행해도_멱등하게_성공한다() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf("20260414"));
+
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
+ }
+
+ @Test
+ void 같은_anchorDate_로_두번_돌려도_결과가_동일하다() throws Exception {
+ String anchor = "20260414";
+ aggregationRepository.save(new StagingRankingAggregation("LAST_7D", anchor, 1L, 10, 0, 0));
+ scoredRepository.save(new StagingRankingScored("LAST_7D", anchor, "control", 1L, 10, 0, 0, 1.0));
- @DisplayName("같은 anchorDate 로 두 번 돌려도 결과가 동일하다 (배치 멱등성).")
- @Test
- void idempotentOnRepeatedRun() throws Exception {
- String anchor = "20260414";
- aggregationRepository.save(new StagingRankingAggregation("LAST_7D", anchor, 1L, 10, 0, 0));
- scoredRepository.save(new StagingRankingScored("LAST_7D", anchor, "control", 1L, 10, 0, 0, 1.0));
-
- jobLauncherTestUtils.setJob(job);
-
- JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(anchor));
- // 재실행을 위해 새 JobInstance 로 실행 (runTimestamp 로 격리)
- JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(anchor));
-
- assertAll(
- () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(aggregationRepository.countByPeriodKey(anchor)).isZero(),
- () -> assertThat(scoredRepository.countByPeriodKey(anchor)).isZero()
- );
+ jobLauncherTestUtils.setJob(job);
+
+ JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(anchor));
+ // 재실행을 위해 새 JobInstance 로 실행 (runTimestamp 로 격리)
+ JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(anchor));
+
+ assertAll(
+ () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED),
+ () -> assertThat(aggregationRepository.countByPeriodKey(anchor)).isZero(),
+ () -> assertThat(scoredRepository.countByPeriodKey(anchor)).isZero()
+ );
+ }
}
- @DisplayName("anchorDate 파라미터가 없으면 Job 이 실패한다.")
- @Test
- void failsWhenAnchorDateMissing() throws Exception {
- jobLauncherTestUtils.setJob(job);
+ @Nested
+ class 파라미터_검증 {
+
+ @Test
+ void anchorDate_파라미터가_없으면_Job_이_실패한다() throws Exception {
+ jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(
- new JobParametersBuilder()
- .addLong("runTimestamp", System.nanoTime())
- .toJobParameters()
- );
+ JobExecution execution = jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder()
+ .addLong("runTimestamp", System.nanoTime())
+ .toJobParameters()
+ );
- assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED);
+ assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED);
+ }
}
private JobParameters paramsOf(String anchorDate) {
From d33a90ea687102e2234b3812015a0ce9970594ca Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 10:16:30 +0900
Subject: [PATCH 19/21] =?UTF-8?q?refactor:=20MV=20=EA=B5=90=EC=B2=B4?=
=?UTF-8?q?=EB=A5=BC=20DELETE+INSERT=20=EB=8B=A8=EC=9D=BC=20TX=20=EC=9B=90?=
=?UTF-8?q?=EC=9E=90=20=EA=B5=90=EC=B2=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step 4 (PurgeMvTasklet) 을 제거하고, Step 5b (PromoteTopToMvTasklet) 가
같은 @Transactional 안에서 DELETE → INSERT 를 수행한다.
READ COMMITTED 에서 커밋 전까지 외부 세션은 이전 MV 를 보고,
커밋 후에는 새 MV 만 보므로 "MV 가 비어있는 순간" 이 노출되지 않는다.
별도 Step 으로 분리했을 때 필요했던 API 전일 fallback 의존도가 줄어들고,
Step 5 실패 시에도 이전 MV 가 그대로 유지되어 더 안전하다.
- PurgeMvTasklet, PurgeMvStepConfig, PurgeMvStepIntegrationTest 삭제
- PromoteTopToMvTasklet: DELETE 7d/30d 를 INSERT 앞에서 같은 TX 로 실행
- RollingRankingJobConfig: purgeMvStep 제거
Job 체인: 0 → 1 → 2 → 3 → 5 → 5b → 7 → 6
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 6 +-
.../step/promote/PromoteTopToMvTasklet.java | 37 ++---
.../ranking/step/purge/PurgeMvStepConfig.java | 30 ----
.../ranking/step/purge/PurgeMvTasklet.java | 57 --------
.../purge/PurgeMvStepIntegrationTest.java | 128 ------------------
5 files changed, 22 insertions(+), 236 deletions(-)
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
delete mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 1907d7eea7..992f6e4679 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -3,7 +3,6 @@
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.batch.job.ranking.step.audit.AuditStepConfig;
import com.loopers.batch.job.ranking.step.promote.PromoteTopToMvStepConfig;
-import com.loopers.batch.job.ranking.step.purge.PurgeMvStepConfig;
import com.loopers.batch.job.ranking.step.redis.RedisRefreshStepConfig;
import com.loopers.batch.job.ranking.step.score.ScoreAggregationStepConfig;
import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig;
@@ -27,7 +26,8 @@
/**
* 롤링 7일 / 30일 랭킹 배치 Job 구성.
*
- * Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 5b → 7 → 6
+ * Step 체인: 0 → 1 → 2 → 3 → 5 → 5b → 7 → 6
+ * Step 5b 가 DELETE + INSERT 를 단일 TX 로 원자 교체하므로 별도 purge Step 불필요.
*/
@Configuration
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
@@ -49,7 +49,6 @@ public Job rollingRankingJob(
@Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep,
@Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep,
@Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
- @Qualifier(PurgeMvStepConfig.STEP_NAME) Step purgeMvStep,
@Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep,
@Qualifier(PromoteTopToMvStepConfig.STEP_NAME) Step promoteTopToMvStep,
@Qualifier(AuditStepConfig.STEP_NAME) Step auditStep,
@@ -62,7 +61,6 @@ public Job rollingRankingJob(
.next(stageViewMetricsStep)
.next(stageLikeMetricsStep)
.next(stageOrderMetricsStep)
- .next(purgeMvStep)
.next(scoreAggregationStep)
.next(promoteTopToMvStep)
.next(auditStep)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
index b921cd8cc2..cf64c81680 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
@@ -23,11 +23,11 @@
import java.util.List;
/**
- * Step 5b — 2차 스테이징(staging_ranking_scored) 에서 TOP 100 만 MV 에 INSERT.
+ * Step 5b — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT.
*
- * (period_type × weight_group) 조합 당 한 번의 단일 SQL:
- * {@code INSERT INTO mv SELECT ... ROW_NUMBER() OVER (ORDER BY score DESC) ... LIMIT 100}.
- * Step 4a/4b 가 사전 DELETE 했으므로 MV 는 "비어있음 → 확정된 TOP 100" 두 상태만 통과한다.
+ * DELETE + INSERT 가 **단일 TX** 안에서 실행되므로 READ COMMITTED 에서
+ * 외부 세션(API) 은 커밋 전까지 이전 MV 를, 커밋 후에는 새 MV 만 봄.
+ * "MV 가 비어있는 순간" 이 물리적으로 노출되지 않는다 (원자 교체).
*/
@Slf4j
@Component
@@ -38,9 +38,8 @@ public class PromoteTopToMvTasklet implements Tasklet {
public static final int TOP_N = 100;
private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
- // 각 period_type 별로 MV 테이블 이름이 다름
- private static final String SQL_LAST_7D = sqlFor("mv_product_rank_last_7d");
- private static final String SQL_LAST_30D = sqlFor("mv_product_rank_last_30d");
+ private static final String INSERT_SQL_LAST_7D = insertSqlFor("mv_product_rank_last_7d");
+ private static final String INSERT_SQL_LAST_30D = insertSqlFor("mv_product_rank_last_30d");
private final JdbcTemplate jdbcTemplate;
@@ -51,31 +50,35 @@ public class PromoteTopToMvTasklet implements Tasklet {
@Transactional
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
+ Date sqlDate = Date.valueOf(anchorDate);
List configs = RankingJobParametersListener.restoreWeightConfigs(
chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext());
+ // 1. DELETE — 이전 MV 제거 (같은 TX 안이라 외부에 아직 안 보임)
+ int deleted7d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", sqlDate);
+ int deleted30d = jdbcTemplate.update(
+ "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", sqlDate);
+
+ // 2. INSERT — TOP 100 적재
Timestamp createdAt = Timestamp.valueOf(LocalDateTime.now());
int totalInserted = 0;
-
for (WeightConfig config : configs) {
- totalInserted += promote(SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D,
+ totalInserted += promote(INSERT_SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D,
anchorDate, config.getGroupName(), createdAt);
- totalInserted += promote(SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D,
+ totalInserted += promote(INSERT_SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D,
anchorDate, config.getGroupName(), createdAt);
}
- log.info("[STEP=promoteTopToMvStep] anchorDate={} groups={} inserted={}",
- anchorDate, configs.size(), totalInserted);
+ log.info("[STEP=promoteTopToMvStep] anchorDate={} deleted7d={} deleted30d={} inserted={}",
+ anchorDate, deleted7d, deleted30d, totalInserted);
- contribution.incrementWriteCount(totalInserted);
+ contribution.incrementWriteCount(deleted7d + deleted30d + totalInserted);
return RepeatStatus.FINISHED;
}
private int promote(String sql, String periodType, LocalDate anchorDate,
String weightGroup, Timestamp createdAt) {
- // SQL 의 ? 출현 순서: period_type, period_key, weight_group (CTE WHERE)
- // anchor_date, created_at (SELECT literal)
- // top_n (WHERE rn <= ?)
return jdbcTemplate.update(
sql,
periodType, anchorDateKey, weightGroup,
@@ -84,7 +87,7 @@ private int promote(String sql, String periodType, LocalDate anchorDate,
);
}
- private static String sqlFor(String mvTable) {
+ private static String insertSqlFor(String mvTable) {
return """
INSERT INTO %s
(anchor_date, weight_group, product_id,
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
deleted file mode 100644
index 131b55ac39..0000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepConfig.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.loopers.batch.job.ranking.step.purge;
-
-import com.loopers.batch.listener.StepMonitorListener;
-import lombok.RequiredArgsConstructor;
-import org.springframework.batch.core.Step;
-import org.springframework.batch.core.repository.JobRepository;
-import org.springframework.batch.core.step.builder.StepBuilder;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.transaction.PlatformTransactionManager;
-
-@Configuration
-@RequiredArgsConstructor
-public class PurgeMvStepConfig {
-
- public static final String STEP_NAME = "purgeMvStep";
-
- private final JobRepository jobRepository;
- private final PlatformTransactionManager transactionManager;
- private final StepMonitorListener stepMonitorListener;
- private final PurgeMvTasklet purgeMvTasklet;
-
- @Bean(STEP_NAME)
- public Step purgeMvStep() {
- return new StepBuilder(STEP_NAME, jobRepository)
- .tasklet(purgeMvTasklet, transactionManager)
- .listener(stepMonitorListener)
- .build();
- }
-}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
deleted file mode 100644
index 4f5628d24d..0000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/purge/PurgeMvTasklet.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.loopers.batch.job.ranking.step.purge;
-
-import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.batch.core.StepContribution;
-import org.springframework.batch.core.configuration.annotation.StepScope;
-import org.springframework.batch.core.scope.context.ChunkContext;
-import org.springframework.batch.core.step.tasklet.Tasklet;
-import org.springframework.batch.repeat.RepeatStatus;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.sql.Date;
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-
-/**
- * Step 4 — 현재 anchorDate 의 LAST_7D + LAST_30D MV row 를 사전 DELETE 한다.
- * Step 5b 의 INSERT 가 돌기 전에 "MV 는 비어있음" 상태를 보장하여,
- * MV 가 거쳐가는 상태를 "비어있음 → 확정된 TOP 100" 두 가지로만 제한한다 (중간 상태 불가시성).
- *
- * 두 DELETE 는 각각 idempotent 이고 같은 anchor_date 기준이므로 단일 Tasklet 에 통합.
- */
-@Slf4j
-@Component
-@StepScope
-@RequiredArgsConstructor
-public class PurgeMvTasklet implements Tasklet {
-
- private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
-
- private final JdbcTemplate jdbcTemplate;
-
- @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
- private String anchorDateKey;
-
- @Override
- @Transactional
- public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
- LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- Date sqlDate = Date.valueOf(anchorDate);
-
- int deleted7d = jdbcTemplate.update(
- "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", sqlDate);
- int deleted30d = jdbcTemplate.update(
- "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", sqlDate);
-
- log.info("[STEP=purgeMvStep] anchorDate={} deleted7d={} deleted30d={}",
- anchorDate, deleted7d, deleted30d);
-
- contribution.incrementWriteCount(deleted7d + deleted30d);
- return RepeatStatus.FINISHED;
- }
-}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
deleted file mode 100644
index 910303a888..0000000000
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/purge/PurgeMvStepIntegrationTest.java
+++ /dev/null
@@ -1,128 +0,0 @@
-package com.loopers.batch.job.ranking.step.purge;
-
-import com.loopers.batch.job.ranking.RollingRankingJobConfig;
-import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import com.loopers.domain.ranking.mv.MvProductRankLast30d;
-import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository;
-import com.loopers.domain.ranking.mv.MvProductRankLast7d;
-import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository;
-import com.loopers.testcontainers.MySqlTestContainersConfig;
-import com.loopers.utils.DatabaseCleanUp;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayNameGeneration;
-import org.junit.jupiter.api.DisplayNameGenerator;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.springframework.batch.core.BatchStatus;
-import org.springframework.batch.core.Job;
-import org.springframework.batch.core.JobExecution;
-import org.springframework.batch.core.JobParameters;
-import org.springframework.batch.core.JobParametersBuilder;
-import org.springframework.batch.test.JobLauncherTestUtils;
-import org.springframework.batch.test.context.SpringBatchTest;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.context.annotation.Import;
-import org.springframework.test.context.TestPropertySource;
-
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertAll;
-
-@SpringBootTest
-@SpringBatchTest
-@Import(MySqlTestContainersConfig.class)
-@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME)
-@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
-class PurgeMvStepIntegrationTest {
-
- private static final LocalDate TARGET_ANCHOR = LocalDate.of(2026, 4, 14);
- private static final LocalDate OTHER_ANCHOR = LocalDate.of(2026, 4, 13);
- private static final String ANCHOR_KEY = "20260414";
- private static final LocalDateTime CREATED = LocalDateTime.of(2026, 4, 15, 1, 0);
-
- @Autowired private JobLauncherTestUtils jobLauncherTestUtils;
- @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job;
- @Autowired private MvProductRankLast7dRepository last7dRepository;
- @Autowired private MvProductRankLast30dRepository last30dRepository;
- @Autowired private DatabaseCleanUp databaseCleanUp;
-
- @AfterEach
- void tearDown() {
- databaseCleanUp.truncateAllTables();
- }
-
- @Nested
- class MV_purge {
-
- @Test
- void 타겟_anchorDate_의_MV_row_만_양쪽_테이블에서_삭제되고_다른_anchor_는_유지된다() throws Exception {
- last7dRepository.save(mv7d(TARGET_ANCHOR, 1L, 1));
- last7dRepository.save(mv7d(OTHER_ANCHOR, 2L, 1));
- last30dRepository.save(mv30d(TARGET_ANCHOR, 1L, 1));
- last30dRepository.save(mv30d(OTHER_ANCHOR, 2L, 1));
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
- () -> assertThat(last30dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero(),
- () -> assertThat(last7dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne(),
- () -> assertThat(last30dRepository.countByAnchorDate(OTHER_ANCHOR)).isOne()
- );
- }
-
- @Test
- void 동일_anchor_같은_상품이_여러_weight_group_에_있으면_전부_삭제된다() throws Exception {
- last7dRepository.save(mv7d(TARGET_ANCHOR, "control", 1L, 1));
- last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_a", 1L, 1));
- last7dRepository.save(mv7d(TARGET_ANCHOR, "experiment_b", 1L, 1));
-
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertAll(
- () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- () -> assertThat(last7dRepository.countByAnchorDate(TARGET_ANCHOR)).isZero()
- );
- }
- }
-
- @Nested
- class 빈_MV {
-
- @Test
- void MV_가_비어_있어도_Job_은_성공한다() throws Exception {
- jobLauncherTestUtils.setJob(job);
- JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY));
-
- assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED);
- }
- }
-
- // -- helpers --
-
- private static MvProductRankLast7d mv7d(LocalDate anchor, long productId, int rank) {
- return mv7d(anchor, "control", productId, rank);
- }
-
- private static MvProductRankLast7d mv7d(LocalDate anchor, String group, long productId, int rank) {
- return new MvProductRankLast7d(anchor, group, productId, 0, 0, 0, 0.0, rank, CREATED);
- }
-
- private static MvProductRankLast30d mv30d(LocalDate anchor, long productId, int rank) {
- return new MvProductRankLast30d(anchor, "control", productId, 0, 0, 0, 0.0, rank, CREATED);
- }
-
- private JobParameters paramsOf(String anchorDate) {
- return new JobParametersBuilder()
- .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate)
- .addLong("runTimestamp", System.nanoTime())
- .toJobParameters();
- }
-}
From c236b24435ba74d06e4987793e0b3f44f6366515 Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 10:22:39 +0900
Subject: [PATCH 20/21] =?UTF-8?q?chore:=20Step=20=EB=B2=88=ED=98=B8?=
=?UTF-8?q?=EB=A5=BC=200~7=20=EC=88=9C=EC=B0=A8=EB=A1=9C=20=EC=9E=AC?=
=?UTF-8?q?=EC=A0=95=EB=A0=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
purge Step 삭제 후 빈 번호(4) + 뒤죽박죽 순서(7→6) 를 정리.
코드 로직 변경 없음, 주석/Javadoc 의 Step 번호만 교체.
이전: 0 → 1 → 2 → 3 → (4 삭제) → 5 → 5b → 7 → 6
이후: 0 → 1 → 2 → 3 → 4(score) → 5(promote) → 6(audit) → 7(redis)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../job/ranking/RollingRankingJobConfig.java | 3 +--
.../job/ranking/step/audit/AuditTasklet.java | 4 ++--
.../step/promote/PromoteTopToMvTasklet.java | 2 +-
.../step/redis/RedisRefreshTasklet.java | 4 ++--
.../score/ScoreAggregationStepConfig.java | 2 +-
.../step/score/StagingScoredWriter.java | 2 +-
.../job/ranking/RollingRankingJobE2ETest.java | 2 +-
.../ranking/RollingRankingJobRestartTest.java | 22 +++++++++----------
.../ScoreAggregationStepIntegrationTest.java | 4 ++--
9 files changed, 22 insertions(+), 23 deletions(-)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 992f6e4679..7dfa3dade2 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -26,8 +26,7 @@
/**
* 롤링 7일 / 30일 랭킹 배치 Job 구성.
*
- * Step 체인: 0 → 1 → 2 → 3 → 5 → 5b → 7 → 6
- * Step 5b 가 DELETE + INSERT 를 단일 TX 로 원자 교체하므로 별도 purge Step 불필요.
+ * Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7
*/
@Configuration
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
index 00b306416c..4f833c3502 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
@@ -24,7 +24,7 @@
import java.util.List;
/**
- * Step 7 — MV 가 "정의된 상태 (정확히 TOP 100)" 인지 불변조건을 검증한다.
+ * Step 6 — MV 가 "정의된 상태 (정확히 TOP 100)" 인지 불변조건을 검증한다.
*
* 검증 항목:
*
@@ -32,7 +32,7 @@
* - MIN(rank_position) = 1, MAX = 100 (1~100 연속)
* - DISTINCT product_id count = 100 (중복 없음)
*
- * 위반 시 Job FAIL → Step 6 (Redis 전파) 차단. 잘못된 MV 가 캐시로 퍼지는 경로를 원천 차단.
+ * 위반 시 Job FAIL → Step 7 (Redis 전파) 차단. 잘못된 MV 가 캐시로 퍼지는 경로를 원천 차단.
*
* CHECKSUM (값 해시 비교) 은 의도적으로 도입하지 않음.
* 랭킹은 "돈이 잘못 움직이지 않는" 도메인이며, score 값 버그는 테스트 코드가 잡을 영역.
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
index cf64c81680..55a07d5446 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
@@ -23,7 +23,7 @@
import java.util.List;
/**
- * Step 5b — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT.
+ * Step 5 — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT.
*
* DELETE + INSERT 가 **단일 TX** 안에서 실행되므로 READ COMMITTED 에서
* 외부 세션(API) 은 커밋 전까지 이전 MV 를, 커밋 후에는 새 MV 만 봄.
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
index f5478883de..50e3764b62 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java
@@ -24,12 +24,12 @@
import java.util.Set;
/**
- * Step 6 — MV 의 확정된 TOP 100 을 Redis ZSET identity cache 로 복제한다.
+ * Step 7 — MV 의 확정된 TOP 100 을 Redis ZSET identity cache 로 복제한다.
*
*
Shadow key 에 ZADD 후 RENAME 으로 원자적 교체 → 조회 중 깜빡임 없음.
* Redis 는 MV 의 identity mirror (score·순서 동일) — 새 계산은 없다.
*
- * Step 6 실패는 치명적이지 않음 — MV 자체는 영속되어 있고
+ *
Step 7 실패는 치명적이지 않음 — MV 자체는 영속되어 있고
* 조회 API 가 MV fallback 으로 동일 응답을 만들 수 있음.
*/
@Slf4j
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
index 9a28fbf632..ebc04c2622 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java
@@ -43,7 +43,7 @@ public JdbcCursorItemReader stagingAggregationCursorR
.name("stagingAggregationCursorReader")
.dataSource(dataSource)
.fetchSize(FETCH_SIZE)
- // Step 5 는 1:1 row 변환 (streaming aggregation 없음) 이므로
+ // Step 4 는 1:1 row 변환 (streaming aggregation 없음) 이므로
// saveState=true (기본값) 로 chunk-mid restart 가 정상 작동
.sql("""
SELECT period_type, period_key, product_id,
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
index c08b815948..6bf3de3999 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java
@@ -14,7 +14,7 @@
import java.util.List;
/**
- * Step 5 Writer — staging_ranking_scored 에 전체 상품 score 적재.
+ * Step 4 Writer — staging_ranking_scored 에 전체 상품 score 적재.
* Step 0 가 해당 anchor 의 scored row 를 비워 놓으므로 여기서는 순수 INSERT.
* 재시작 안전성을 위해 UPSERT 로 기록 (같은 PK 재실행 시 값 갱신).
*/
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
index 00b7889e8a..57d69c674b 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
@@ -42,7 +42,7 @@
import static org.junit.jupiter.api.Assertions.assertAll;
/**
- * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 6 까지의 통과 검증.
+ * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 7 까지의 통과 검증.
* 원천 3개 테이블에 시드 → Job 실행 → MV + audit + Redis ZSET 결과 검증.
*/
@SpringBootTest
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
index 7d3b68c344..0d1f321236 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java
@@ -79,9 +79,9 @@ class RollingRankingJobRestartTest {
@Autowired private RedisCleanUp redisCleanUp;
@SpyBean private StagingViewMetricsWriter viewWriter;
- // Step 5 Writer — 일반 @Component 라 SpyBean 정상 작동.
+ // Step 4 Writer — 일반 @Component 라 SpyBean 정상 작동.
// weight_group 은 이제 ExecutionContext 스냅샷에서 읽으므로
- // WeightConfigRepository spy 로는 Step 5/5b 를 실패시킬 수 없음.
+ // WeightConfigRepository spy 로는 Step 4/5 를 실패시킬 수 없음.
@SpyBean private StagingScoredWriter scoredWriter;
@AfterEach
@@ -142,8 +142,8 @@ class Step5_Score_실패 {
saveView(pid, IN_7D, 10);
}
- // StagingScoredWriter 첫 write 에서 throw → Step 5 fail
- Mockito.doThrow(new RuntimeException("의도적 Step 5 실패"))
+ // StagingScoredWriter 첫 write 에서 throw → Step 4 fail
+ Mockito.doThrow(new RuntimeException("의도적 Step 4 실패"))
.when(scoredWriter).write(any());
JobParameters params = paramsOf(ANCHOR_KEY, 2L);
@@ -164,20 +164,20 @@ class Step5_Score_실패 {
saveView(pid, IN_7D, 10);
}
- // StagingScoredWriter 의 write 를 전부 통과시켜 Step 5 완주.
- // Step 5b (PromoteTopToMv) 에서 실패를 유도하기 위해
+ // StagingScoredWriter 의 write 를 전부 통과시켜 Step 4 완주.
+ // Step 5 (PromoteTopToMv) 에서 실패를 유도하기 위해
// MV INSERT SQL 이 실행되기 전에 MV 테이블을 DROP 하는 대신,
- // 단순히 Step 5 완주 후 MV 가 비어있음을 검증.
- // (Step 5b 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가)
- // 여기서는 Step 5 까지의 정상 완주 + "MV 는 Step 5b 전에 항상 비어있다"를 확인.
+ // 단순히 Step 4 완주 후 MV 가 비어있음을 검증.
+ // (Step 5 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가)
+ // 여기서는 Step 4 까지의 정상 완주 + "MV 는 Step 5 전에 항상 비어있다"를 확인.
JobParameters params = paramsOf(ANCHOR_KEY, 3L);
JobExecution exec = jobLauncher.run(job, params);
assertAll(
() -> assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED),
- // Step 5 완주 → 2차 staging 적재 확인
+ // Step 4 완주 → 2차 staging 적재 확인
() -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L),
- // Step 5b 도 완주 → MV 에 5 product
+ // Step 5 도 완주 → MV 에 5 product
() -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(5L)
);
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
index 2dd5b406d8..ee1f730b6f 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java
@@ -33,9 +33,9 @@
import static org.junit.jupiter.api.Assertions.assertAll;
/**
- * Step 5 — 1차 staging 의 raw sum 에 score 를 계산해 2차 staging 에 적재하는 파이프라인 검증.
+ * Step 4 — 1차 staging 의 raw sum 에 score 를 계산해 2차 staging 에 적재하는 파이프라인 검증.
* Step 0~3 으로 1차가 먼저 채워지므로, 이 테스트는 view/like/order 원천에 시드하고
- * Job 전체를 돌려 Step 5 결과만 확인한다.
+ * Job 전체를 돌려 Step 4 결과만 확인한다.
*/
@SpringBootTest
@SpringBatchTest
From 776b6a5083542d4a6b2560bcb5ded64866a5864f Mon Sep 17 00:00:00 2001
From: ghtjr410
Date: Fri, 17 Apr 2026 15:11:43 +0900
Subject: [PATCH 21/21] =?UTF-8?q?refactor:=20audit=20=EA=B2=80=EC=A6=9D=20?=
=?UTF-8?q?Step=20=EC=A0=9C=EA=B1=B0,=20=EC=8B=A4=ED=96=89=20=EC=9D=B4?=
=?UTF-8?q?=EB=A0=A5=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20promote=20Step?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
audit의 count/rank/distinct 검증은 SQL이 구조적으로 보장하는 불변조건이라
런타임 재검증이 불필요하다. 검증 + 오염 MV 삭제 로직을 제거하고,
실행 이력(audit_log) 기록을 Step 5(promote)의 동일 TX로 이동하여
MV 적재와 이력이 원자적으로 커밋되도록 변경한다.
- AuditTasklet, AuditStepConfig 삭제
- PromoteTopToMvTasklet에 audit_log 기록 추가 (같은 TX)
- BatchAuditLog.failed() 제거 (FAILED 경로 소멸)
- Job Step 체인 8→7 (Step 0~6)
---
.../application/ranking/RankingService.java | 2 +-
.../job/ranking/RollingRankingJobConfig.java | 5 +-
.../ranking/step/audit/AuditStepConfig.java | 30 ----
.../job/ranking/step/audit/AuditTasklet.java | 151 ------------------
.../step/promote/PromoteTopToMvTasklet.java | 25 ++-
.../domain/ranking/audit/BatchAuditLog.java | 21 +--
.../job/ranking/RollingRankingJobE2ETest.java | 4 +-
week10/blog-final-final.md | 0
week10/blog-final.md | 0
9 files changed, 27 insertions(+), 211 deletions(-)
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
delete mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
create mode 100644 week10/blog-final-final.md
create mode 100644 week10/blog-final.md
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
index 10ac34e3d0..54dc0cadb4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java
@@ -97,7 +97,7 @@ private List fallbackFromMv(RankingPeriod period, LocalDate date, int
int offset = page * size;
// 현재 anchor 의 MV 가 비어있으면 전일 anchor 로 자동 fallback (최대 3일).
- // Step 7 (audit) 실패 시 오염 MV 를 DELETE 하므로 비어있을 수 있음.
+ // 배치 미실행 또는 해당 anchor 에 데이터가 없으면 비어있을 수 있음.
// "잘못된 랭킹" 보다 "어제 랭킹이라도 보여주기" 가 사용자 경험상 나음.
for (int retry = 0; retry < MV_FALLBACK_MAX_DAYS; retry++) {
List rows = switch (period) {
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
index 7dfa3dade2..73e3430ccd 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java
@@ -1,7 +1,6 @@
package com.loopers.batch.job.ranking;
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import com.loopers.batch.job.ranking.step.audit.AuditStepConfig;
import com.loopers.batch.job.ranking.step.promote.PromoteTopToMvStepConfig;
import com.loopers.batch.job.ranking.step.redis.RedisRefreshStepConfig;
import com.loopers.batch.job.ranking.step.score.ScoreAggregationStepConfig;
@@ -26,7 +25,7 @@
/**
* 롤링 7일 / 30일 랭킹 배치 Job 구성.
*
- * Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7
+ * Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 6
*/
@Configuration
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME)
@@ -50,7 +49,6 @@ public Job rollingRankingJob(
@Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep,
@Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep,
@Qualifier(PromoteTopToMvStepConfig.STEP_NAME) Step promoteTopToMvStep,
- @Qualifier(AuditStepConfig.STEP_NAME) Step auditStep,
@Qualifier(RedisRefreshStepConfig.STEP_NAME) Step redisRefreshStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
@@ -62,7 +60,6 @@ public Job rollingRankingJob(
.next(stageOrderMetricsStep)
.next(scoreAggregationStep)
.next(promoteTopToMvStep)
- .next(auditStep)
.next(redisRefreshStep)
.build();
}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
deleted file mode 100644
index 0a7d3609b8..0000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditStepConfig.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.loopers.batch.job.ranking.step.audit;
-
-import com.loopers.batch.listener.StepMonitorListener;
-import lombok.RequiredArgsConstructor;
-import org.springframework.batch.core.Step;
-import org.springframework.batch.core.repository.JobRepository;
-import org.springframework.batch.core.step.builder.StepBuilder;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.transaction.PlatformTransactionManager;
-
-@Configuration
-@RequiredArgsConstructor
-public class AuditStepConfig {
-
- public static final String STEP_NAME = "auditStep";
-
- private final JobRepository jobRepository;
- private final PlatformTransactionManager transactionManager;
- private final StepMonitorListener stepMonitorListener;
- private final AuditTasklet auditTasklet;
-
- @Bean(STEP_NAME)
- public Step auditStep() {
- return new StepBuilder(STEP_NAME, jobRepository)
- .tasklet(auditTasklet, transactionManager)
- .listener(stepMonitorListener)
- .build();
- }
-}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
deleted file mode 100644
index 4f833c3502..0000000000
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/audit/AuditTasklet.java
+++ /dev/null
@@ -1,151 +0,0 @@
-package com.loopers.batch.job.ranking.step.audit;
-
-import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
-import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor;
-import com.loopers.domain.ranking.audit.BatchAuditLog;
-import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
-import com.loopers.domain.ranking.weight.WeightConfig;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.batch.core.StepContribution;
-import org.springframework.batch.core.configuration.annotation.StepScope;
-import org.springframework.batch.core.scope.context.ChunkContext;
-import org.springframework.batch.core.step.tasklet.Tasklet;
-import org.springframework.batch.repeat.RepeatStatus;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.sql.Date;
-import java.time.LocalDate;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Step 6 — MV 가 "정의된 상태 (정확히 TOP 100)" 인지 불변조건을 검증한다.
- *
- * 검증 항목:
- *
- * - count = 100 (TOP 100 완전 적재)
- * - MIN(rank_position) = 1, MAX = 100 (1~100 연속)
- * - DISTINCT product_id count = 100 (중복 없음)
- *
- * 위반 시 Job FAIL → Step 7 (Redis 전파) 차단. 잘못된 MV 가 캐시로 퍼지는 경로를 원천 차단.
- *
- * CHECKSUM (값 해시 비교) 은 의도적으로 도입하지 않음.
- * 랭킹은 "돈이 잘못 움직이지 않는" 도메인이며, score 값 버그는 테스트 코드가 잡을 영역.
- */
-@Slf4j
-@Component
-@StepScope
-@RequiredArgsConstructor
-public class AuditTasklet implements Tasklet {
-
- private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd");
- private static final int EXPECTED_COUNT = 100;
-
- private static final String AUDIT_SQL_TEMPLATE = """
- SELECT COUNT(*) AS row_count,
- COALESCE(MIN(rank_position), 0) AS min_rank,
- COALESCE(MAX(rank_position), 0) AS max_rank,
- COUNT(DISTINCT product_id) AS distinct_products
- FROM %s
- WHERE anchor_date = ?
- AND weight_group = ?
- """;
-
- private static final String AUDIT_SQL_LAST_7D = AUDIT_SQL_TEMPLATE.formatted("mv_product_rank_last_7d");
- private static final String AUDIT_SQL_LAST_30D = AUDIT_SQL_TEMPLATE.formatted("mv_product_rank_last_30d");
-
- private final JdbcTemplate jdbcTemplate;
- private final BatchAuditLogRepository auditLogRepository;
-
- @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
- private String anchorDateKey;
-
- @Value("#{stepExecution.jobExecution.id}")
- private Long jobExecutionId;
-
- @Override
- @Transactional
- public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
- LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT);
- List configs = RankingJobParametersListener.restoreWeightConfigs(
- chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext());
-
- List failures = new ArrayList<>();
- for (WeightConfig config : configs) {
- failures.addAll(auditPeriod(
- anchorDate, StagingAggregationProcessor.PERIOD_LAST_7D, config.getGroupName(), AUDIT_SQL_LAST_7D));
- failures.addAll(auditPeriod(
- anchorDate, StagingAggregationProcessor.PERIOD_LAST_30D, config.getGroupName(), AUDIT_SQL_LAST_30D));
- }
-
- if (!failures.isEmpty()) {
- // 오염 격리: 불완전한 MV 를 API 가 서빙하지 않도록 해당 anchor DELETE.
- // API 는 현재 anchor MV 가 비어있으면 전일 anchor 로 자동 fallback 한다.
- // "잘못된 랭킹 보여주기" 보다 "어제 랭킹이라도 보여주기" 가 나음.
- int deleted7d = jdbcTemplate.update(
- "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", Date.valueOf(anchorDate));
- int deleted30d = jdbcTemplate.update(
- "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", Date.valueOf(anchorDate));
- log.warn("[STEP=auditStep] 오염 MV 격리 완료: anchorDate={} deleted7d={} deleted30d={}",
- anchorDate, deleted7d, deleted30d);
-
- String message = "MV audit 실패 (오염 MV 삭제됨): " + String.join(" / ", failures);
- log.error("[STEP=auditStep] FAILED anchorDate={} reasons={}", anchorDate, failures);
- throw new IllegalStateException(message);
- }
-
- log.info("[STEP=auditStep] OK anchorDate={} groups={}", anchorDate, configs.size());
- return RepeatStatus.FINISHED;
- }
-
- private List auditPeriod(LocalDate anchorDate, String periodType,
- String weightGroup, String sql) {
- AuditResult result = jdbcTemplate.queryForObject(sql,
- (rs, rn) -> new AuditResult(
- rs.getInt("row_count"),
- rs.getInt("min_rank"),
- rs.getInt("max_rank"),
- rs.getInt("distinct_products")
- ),
- Date.valueOf(anchorDate), weightGroup);
-
- // 불변조건: (1) count ≤ TOP_N, (2) rank 가 1..count 로 연속, (3) product_id 중복 없음.
- // count 는 "정확히 100" 이 아니라 "TOP_N 이하 + 실제 상품 수만큼 채워짐" 이면 OK
- // (테스트·초기 운영처럼 상품이 적은 환경도 정상 취급).
- List problems = new ArrayList<>();
- if (result.rowCount > EXPECTED_COUNT) {
- problems.add(String.format(
- "%s/%s count=%d exceeds TOP_N(%d)",
- periodType, weightGroup, result.rowCount, EXPECTED_COUNT));
- }
- if (result.rowCount > 0) {
- if (result.minRank != 1 || result.maxRank != result.rowCount) {
- problems.add(String.format(
- "%s/%s rank not contiguous: [%d,%d] for count=%d",
- periodType, weightGroup, result.minRank, result.maxRank, result.rowCount));
- }
- if (result.distinctProducts != result.rowCount) {
- problems.add(String.format(
- "%s/%s duplicate product_id: distinct=%d count=%d",
- periodType, weightGroup, result.distinctProducts, result.rowCount));
- }
- }
-
- if (problems.isEmpty()) {
- auditLogRepository.save(BatchAuditLog.ok(
- jobExecutionId, anchorDate, periodType, weightGroup, result.rowCount));
- } else {
- auditLogRepository.save(BatchAuditLog.failed(
- jobExecutionId, anchorDate, periodType, weightGroup,
- result.rowCount, String.join(" ; ", problems)));
- }
- return problems;
- }
-
- private record AuditResult(int rowCount, int minRank, int maxRank, int distinctProducts) {}
-}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
index 55a07d5446..e30b496e38 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java
@@ -2,6 +2,8 @@
import com.loopers.batch.job.ranking.param.RankingJobParametersListener;
import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor;
+import com.loopers.domain.ranking.audit.BatchAuditLog;
+import com.loopers.domain.ranking.audit.BatchAuditLogRepository;
import com.loopers.domain.ranking.weight.WeightConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -23,9 +25,9 @@
import java.util.List;
/**
- * Step 5 — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT.
+ * Step 5 — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT + 실행 이력 기록.
*
- * DELETE + INSERT 가 **단일 TX** 안에서 실행되므로 READ COMMITTED 에서
+ *
DELETE + INSERT + audit_log 기록이 **단일 TX** 안에서 실행되므로 MVCC 에 의해
* 외부 세션(API) 은 커밋 전까지 이전 MV 를, 커밋 후에는 새 MV 만 봄.
* "MV 가 비어있는 순간" 이 물리적으로 노출되지 않는다 (원자 교체).
*/
@@ -42,10 +44,14 @@ public class PromoteTopToMvTasklet implements Tasklet {
private static final String INSERT_SQL_LAST_30D = insertSqlFor("mv_product_rank_last_30d");
private final JdbcTemplate jdbcTemplate;
+ private final BatchAuditLogRepository auditLogRepository;
@Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}")
private String anchorDateKey;
+ @Value("#{stepExecution.jobExecution.id}")
+ private Long jobExecutionId;
+
@Override
@Transactional
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
@@ -64,10 +70,19 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon
Timestamp createdAt = Timestamp.valueOf(LocalDateTime.now());
int totalInserted = 0;
for (WeightConfig config : configs) {
- totalInserted += promote(INSERT_SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D,
- anchorDate, config.getGroupName(), createdAt);
- totalInserted += promote(INSERT_SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D,
+ int inserted7d = promote(INSERT_SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D,
anchorDate, config.getGroupName(), createdAt);
+ int inserted30d = promote(INSERT_SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D,
+ anchorDate, config.getGroupName(), createdAt);
+ totalInserted += inserted7d + inserted30d;
+
+ // 3. 실행 이력 기록 — MV 적재와 같은 TX 에서 커밋
+ auditLogRepository.save(BatchAuditLog.ok(
+ jobExecutionId, anchorDate,
+ StagingAggregationProcessor.PERIOD_LAST_7D, config.getGroupName(), inserted7d));
+ auditLogRepository.save(BatchAuditLog.ok(
+ jobExecutionId, anchorDate,
+ StagingAggregationProcessor.PERIOD_LAST_30D, config.getGroupName(), inserted30d));
}
log.info("[STEP=promoteTopToMvStep] anchorDate={} deleted7d={} deleted30d={} inserted={}",
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
index 6880dda04a..6c972dfa33 100644
--- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java
@@ -13,9 +13,9 @@
import java.time.LocalDateTime;
/**
- * Step 7 (audit) 의 검증 결과 기록.
- * BATCH_JOB_EXECUTION 이 "Job 이 COMPLETED 되었는가" 를 보장한다면,
- * 이 테이블은 "결과 데이터 자체가 불변조건을 만족하는가" 의 비즈니스 감사 로그.
+ * MV 적재 실행 이력.
+ * "이 anchor, 이 period, 이 weight_group 에 N건 적재 완료" 를 기록한다.
+ * Step 5 (promote) 에서 MV INSERT 와 같은 TX 안에서 커밋된다.
*/
@Entity
@Table(
@@ -29,7 +29,6 @@
public class BatchAuditLog {
public static final String STATUS_OK = "OK";
- public static final String STATUS_FAILED = "FAILED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -76,18 +75,4 @@ public static BatchAuditLog ok(Long jobExecutionId, LocalDate anchorDate,
return log;
}
- public static BatchAuditLog failed(Long jobExecutionId, LocalDate anchorDate,
- String periodType, String weightGroup,
- int rowCount, String reason) {
- BatchAuditLog log = new BatchAuditLog();
- log.jobExecutionId = jobExecutionId;
- log.anchorDate = anchorDate;
- log.periodType = periodType;
- log.weightGroup = weightGroup;
- log.status = STATUS_FAILED;
- log.rowCount = rowCount;
- log.reason = reason;
- log.createdAt = LocalDateTime.now();
- return log;
- }
}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
index 57d69c674b..38a55b0c4e 100644
--- a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java
@@ -42,8 +42,8 @@
import static org.junit.jupiter.api.Assertions.assertAll;
/**
- * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 7 까지의 통과 검증.
- * 원천 3개 테이블에 시드 → Job 실행 → MV + audit + Redis ZSET 결과 검증.
+ * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 6 까지의 통과 검증.
+ * 원천 3개 테이블에 시드 → Job 실행 → MV + audit_log + Redis ZSET 결과 검증.
*/
@SpringBootTest
@SpringBatchTest
diff --git a/week10/blog-final-final.md b/week10/blog-final-final.md
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/week10/blog-final.md b/week10/blog-final.md
new file mode 100644
index 0000000000..e69de29bb2