items = page.rows().stream()
@@ -36,7 +38,8 @@ public static RankingListInfo from(RankingPage page) {
page.totalElements(),
page.totalPages(),
toDataSource(page.listSource()),
- page.rankingSnapshotId()
+ page.rankingSnapshotId(),
+ page.mvPublishVersion()
);
}
@@ -51,6 +54,8 @@ private static String toDataSource(RankingListSource source) {
case REDIS_ZSET_SNAPSHOT -> "REDIS_SNAPSHOT";
case FALLBACK_DB_LATEST -> "FALLBACK_LATEST";
case DEGRADED_EMPTY -> "DEGRADED";
+ case MV_WEEKLY -> "MV_WEEKLY";
+ case MV_MONTHLY -> "MV_MONTHLY";
};
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java
index 465b3ea91c..cefeb9079c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java
@@ -17,5 +17,11 @@ public enum RankingListSource {
FALLBACK_DB_LATEST,
/** Redis를 읽지 못했고 fallback도 쓰지 않았거나 결과가 비었을 때 */
- DEGRADED_EMPTY
+ DEGRADED_EMPTY,
+
+ /** 주간 랭킹 MV({@code mv_product_rank_weekly}) */
+ MV_WEEKLY,
+
+ /** 월간 랭킹 MV({@code mv_product_rank_monthly}) */
+ MV_MONTHLY
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java
new file mode 100644
index 0000000000..186610331c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java
@@ -0,0 +1,20 @@
+package com.loopers.domain.ranking;
+
+/**
+ * 주간/월간 랭킹 MV 조회용 기간 구분. 배치 Job 파라미터 {@code period}와 동일한 대문자 값을 사용한다.
+ */
+public enum RankingMvPeriod {
+ WEEKLY,
+ MONTHLY;
+
+ public static RankingMvPeriod parse(String raw) {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("period는 필수이며 비어 있으면 안 됩니다.");
+ }
+ try {
+ return RankingMvPeriod.valueOf(raw.trim().toUpperCase());
+ } catch (IllegalArgumentException ex) {
+ throw new IllegalArgumentException("period는 WEEKLY 또는 MONTHLY만 허용됩니다.");
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java
new file mode 100644
index 0000000000..cdbc3de30f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.domain.ranking;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 주간/월간 랭킹 MV 읽기 포트. 구현은 infrastructure.
+ *
+ * 조회는 요청당 {@code MAX(version)}을 한 번 정한 뒤 동일 버전 행만 읽는다(로드맵 3.6 active version 고정).
+ */
+public interface RankingMvReadRepository {
+
+ Optional findMaxVersionForWeekly(String periodKey);
+
+ Optional findMaxVersionForMonthly(String periodKey);
+
+ List findWeeklyByPeriodKeyAndVersionOrdered(String periodKey, int version);
+
+ List findMonthlyByPeriodKeyAndVersionOrdered(String periodKey, int version);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java
new file mode 100644
index 0000000000..f65d7e01be
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java
@@ -0,0 +1,81 @@
+package com.loopers.domain.ranking;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/**
+ * GET 랭킹의 {@code period}/{@code periodKey} 검증. 배치 {@code RankingBatchJobParameters}와 동일 규칙.
+ */
+public final class RankingMvRequestValidator {
+
+ private static final Pattern WEEKLY_KEY = Pattern.compile("^\\d{4}W\\d{2}$");
+ private static final Pattern MONTHLY_KEY = Pattern.compile("^\\d{6}$");
+
+ private RankingMvRequestValidator() {
+ }
+
+ public static void validateMutualExclusion(
+ Optional dateRaw,
+ boolean mvRequested) {
+ if (mvRequested && dateRaw.filter(s -> !s.isBlank()).isPresent()) {
+ throw new CoreException(
+ ErrorType.BAD_REQUEST,
+ "주간/월간(period·periodKey) 조회와 date는 함께 사용할 수 없습니다.");
+ }
+ }
+
+ public static void validateMvPairPresent(boolean periodPresent, boolean periodKeyPresent) {
+ if (periodPresent != periodKeyPresent) {
+ throw new CoreException(
+ ErrorType.BAD_REQUEST,
+ "period와 periodKey는 함께 지정해야 합니다.");
+ }
+ }
+
+ public static RankingMvPeriod parsePeriod(String periodRaw) {
+ try {
+ return RankingMvPeriod.parse(periodRaw);
+ } catch (IllegalArgumentException ex) {
+ throw new CoreException(ErrorType.BAD_REQUEST, ex.getMessage());
+ }
+ }
+
+ public static void validatePeriodKey(RankingMvPeriod period, String periodKeyRaw) {
+ if (periodKeyRaw == null || periodKeyRaw.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "periodKey는 필수이며 비어 있으면 안 됩니다.");
+ }
+ try {
+ switch (period) {
+ case WEEKLY -> validateWeeklyKey(periodKeyRaw);
+ case MONTHLY -> validateMonthlyKey(periodKeyRaw);
+ }
+ } catch (IllegalArgumentException ex) {
+ throw new CoreException(ErrorType.BAD_REQUEST, ex.getMessage());
+ }
+ }
+
+ private static void validateWeeklyKey(String periodKey) {
+ if (!WEEKLY_KEY.matcher(periodKey).matches()) {
+ throw new IllegalArgumentException(
+ "period가 WEEKLY일 때 periodKey는 yyyyWww 형식이어야 합니다. 예: 2026W15");
+ }
+ int week = Integer.parseInt(periodKey.substring(periodKey.indexOf('W') + 1));
+ if (week < 1 || week > 53) {
+ throw new IllegalArgumentException("주차는 01~53 범위여야 합니다.");
+ }
+ }
+
+ private static void validateMonthlyKey(String periodKey) {
+ if (!MONTHLY_KEY.matcher(periodKey).matches()) {
+ throw new IllegalArgumentException(
+ "period가 MONTHLY일 때 periodKey는 yyyyMM 형식이어야 합니다. 예: 202604");
+ }
+ int month = Integer.parseInt(periodKey.substring(4, 6));
+ if (month < 1 || month > 12) {
+ throw new IllegalArgumentException("월은 01~12 범위여야 합니다.");
+ }
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java
new file mode 100644
index 0000000000..93cb47e151
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java
@@ -0,0 +1,13 @@
+package com.loopers.domain.ranking;
+
+import java.math.BigDecimal;
+
+/**
+ * MV 테이블에서 읽은 한 행(순위·상품·점수). Hydration 전 단계.
+ *
+ * @param rankValue 전역 순위(1-based), 컬럼 {@code rank}
+ * @param productId 상품 ID
+ * @param score 배치가 저장한 점수
+ */
+public record RankingMvTableRow(int rankValue, long productId, BigDecimal score) {
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java
index 6a616b50e6..27216867f2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java
@@ -12,6 +12,7 @@
* @param totalPages 총 페이지 수 (1부터)
* @param listSource 목록 생성 경로(Redis / DB fallback / degraded)
* @param rankingSnapshotId 스냅샷 조회 시 발급·요청한 UUID, 라이브 조회면 null
+ * @param mvPublishVersion 주간/월간 MV 조회 시 요청 시작 시점의 활성 버전(동일 요청 내 total·rows 일관성), 일간이면 null
*/
public record RankingPage(
List rows,
@@ -20,6 +21,7 @@ public record RankingPage(
long totalElements,
int totalPages,
RankingListSource listSource,
- String rankingSnapshotId
+ String rankingSnapshotId,
+ Integer mvPublishVersion
) {
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java
index 66cb6f617a..68eeb19c89 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java
@@ -47,6 +47,7 @@ public class RankingQueryService {
private final RankingReadRepository rankingReadRepository;
private final RankingSnapshotRepository rankingSnapshotRepository;
+ private final RankingMvReadRepository rankingMvReadRepository;
private final ProductRepository productRepository;
private final BrandService brandService;
private final LikeService likeService;
@@ -57,6 +58,7 @@ public class RankingQueryService {
public RankingQueryService(
RankingReadRepository rankingReadRepository,
RankingSnapshotRepository rankingSnapshotRepository,
+ RankingMvReadRepository rankingMvReadRepository,
ProductRepository productRepository,
BrandService brandService,
LikeService likeService,
@@ -65,6 +67,7 @@ public RankingQueryService(
@Value("${app.ranking.snapshot-ttl-seconds:600}") long snapshotTtlSeconds) {
this.rankingReadRepository = rankingReadRepository;
this.rankingSnapshotRepository = rankingSnapshotRepository;
+ this.rankingMvReadRepository = rankingMvReadRepository;
this.productRepository = productRepository;
this.brandService = brandService;
this.likeService = likeService;
@@ -73,6 +76,116 @@ public RankingQueryService(
this.snapshotTtlSeconds = snapshotTtlSeconds;
}
+ /**
+ * 주간/월간 MV에서 랭킹 페이지를 조회한다. 행 수는 최대 100으로 캡한다.
+ *
+ * @param period WEEKLY 또는 MONTHLY
+ * @param periodKey 검증된 기간 키(주간 yyyyWww, 월간 yyyyMM)
+ * @param pageOneBased 페이지 (1부터)
+ * @param size 페이지 크기
+ * @return MV 기준 랭킹 페이지
+ */
+ public RankingPage loadMvPage(
+ RankingMvPeriod period,
+ String periodKey,
+ int pageOneBased,
+ int size) {
+ validatePageAndSize(pageOneBased, size);
+ RankingListSource listSource = switch (period) {
+ case WEEKLY -> RankingListSource.MV_WEEKLY;
+ case MONTHLY -> RankingListSource.MV_MONTHLY;
+ };
+ Optional maxVersion = switch (period) {
+ case WEEKLY -> rankingMvReadRepository.findMaxVersionForWeekly(periodKey);
+ case MONTHLY -> rankingMvReadRepository.findMaxVersionForMonthly(periodKey);
+ };
+ if (maxVersion.isEmpty()) {
+ return new RankingPage(
+ List.of(), pageOneBased, size, 0L, 0, listSource, null, null);
+ }
+ int activeVersion = maxVersion.get();
+ List all = switch (period) {
+ case WEEKLY -> rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered(
+ periodKey, activeVersion);
+ case MONTHLY -> rankingMvReadRepository.findMonthlyByPeriodKeyAndVersionOrdered(
+ periodKey, activeVersion);
+ };
+ List capped = all.stream().limit(100).toList();
+ long total = capped.size();
+ int totalPages = computeTotalPages(total, size);
+ if (total == 0L) {
+ return new RankingPage(
+ List.of(), pageOneBased, size, 0L, 0, listSource, null, activeVersion);
+ }
+ long startIndex = (long) (pageOneBased - 1) * size;
+ if (startIndex >= total) {
+ meterRegistry.counter("ranking.mv.page_beyond", "period", period.name()).increment();
+ log.debug(
+ "ranking.mv.page_beyond period={} periodKey={} total={} page={} size={}",
+ period,
+ periodKey,
+ total,
+ pageOneBased,
+ size);
+ return new RankingPage(
+ List.of(), pageOneBased, size, total, totalPages, listSource, null, activeVersion);
+ }
+ int from = (int) startIndex;
+ int to = (int) Math.min(startIndex + size, total);
+ List slice = capped.subList(from, to);
+ List rows = hydrateMvRows(slice);
+ return new RankingPage(
+ rows, pageOneBased, size, total, totalPages, listSource, null, activeVersion);
+ }
+
+ /**
+ * 주간/월간 MV 행을 랭킹 행으로 변환한다.
+ *
+ * @param slice 주간/월간 MV 행
+ * @return 랭킹 행
+ */
+ private List hydrateMvRows(List slice) {
+ if (slice.isEmpty()) {
+ return List.of();
+ }
+ List parsedIds = slice.stream().map(RankingMvTableRow::productId).toList();
+ Map productMap = productRepository.findByIdInAndNotDeletedAsMap(parsedIds);
+ List brandIds = productMap.values().stream()
+ .map(ProductModel::getBrandId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ Map brandMap = brandIds.isEmpty()
+ ? Map.of()
+ : brandService.findByIdAndNotDeletedIn(brandIds);
+ Map likeMap = likeService.getLikeCountByProductIdsFromStats(parsedIds);
+
+ List rows = new ArrayList<>();
+ for (RankingMvTableRow row : slice) {
+ ProductModel product = productMap.get(row.productId());
+ if (product == null) {
+ continue;
+ }
+ BrandModel brand = brandMap.get(product.getBrandId());
+ if (brand == null) {
+ continue;
+ }
+ long likeCount = likeMap.getOrDefault(row.productId(), 0L);
+ rows.add(new RankingRow(
+ row.rankValue(),
+ row.productId(),
+ row.score().doubleValue(),
+ product.getName(),
+ product.getPrice(),
+ product.getBrandId(),
+ brand.getName(),
+ likeCount,
+ product.getStockQuantity()
+ ));
+ }
+ return rows;
+ }
+
/**
* 일간 랭킹 페이지 조회
* @param rankingDate 랭킹 일자
@@ -209,12 +322,12 @@ private RankingPage loadPageFromZsetKey(
int totalPages = computeTotalPages(total, size);
if (total == 0L) {
return new RankingPage(
- List.of(), pageOneBased, size, 0L, 0, listSource, rankingSnapshotIdEcho);
+ List.of(), pageOneBased, size, 0L, 0, listSource, rankingSnapshotIdEcho, null);
}
long startIndex = (long) (pageOneBased - 1) * size;
if (startIndex >= total) {
return new RankingPage(
- List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho);
+ List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null);
}
long endIndex = Math.min(startIndex + size - 1, total - 1);
List entries;
@@ -233,7 +346,7 @@ private RankingPage loadPageFromZsetKey(
}
if (parsedIds.isEmpty()) {
return new RankingPage(
- List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho);
+ List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null);
}
Map productMap = productRepository.findByIdInAndNotDeletedAsMap(parsedIds);
@@ -278,7 +391,8 @@ private RankingPage loadPageFromZsetKey(
product.getStockQuantity()
));
}
- return new RankingPage(rows, pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho);
+ return new RankingPage(
+ rows, pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null);
}
private RankingPage onRedisFailure(
@@ -297,7 +411,14 @@ private RankingPage onRedisFailure(
operation, key, ex.getClass().getSimpleName(), ex.getMessage());
if (!allowDbFallbackOnRedisFailure || !fallbackOnRedisFailure) {
return new RankingPage(
- List.of(), pageOneBased, size, 0L, 0, RankingListSource.DEGRADED_EMPTY, rankingSnapshotIdEcho);
+ List.of(),
+ pageOneBased,
+ size,
+ 0L,
+ 0,
+ RankingListSource.DEGRADED_EMPTY,
+ rankingSnapshotIdEcho,
+ null);
}
return loadPageFromLatestProducts(pageOneBased, size);
} finally {
@@ -324,6 +445,7 @@ private RankingPage loadPageFromLatestProducts(int pageOneBased, int size) {
productPage.getTotalElements(),
productPage.getTotalPages(),
RankingListSource.DEGRADED_EMPTY,
+ null,
null
);
}
@@ -366,6 +488,7 @@ private RankingPage loadPageFromLatestProducts(int pageOneBased, int size) {
productPage.getTotalElements(),
productPage.getTotalPages(),
RankingListSource.FALLBACK_DB_LATEST,
+ null,
null
);
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java
new file mode 100644
index 0000000000..4e4dab824b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java
@@ -0,0 +1,53 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Table(
+ name = "mv_product_rank_monthly",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_mv_product_rank_monthly_period_product",
+ columnNames = {"period_key", "product_id"}
+ )
+)
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class MvProductRankMonthlyEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_key", nullable = false, length = 16)
+ private String periodKey;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "`rank`", nullable = false)
+ private int rankValue;
+
+ @Column(name = "score", nullable = false, precision = 24, scale = 8)
+ private BigDecimal score;
+
+ @Column(name = "version", nullable = false)
+ private int version;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java
new file mode 100644
index 0000000000..1f7a8ea605
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface MvProductRankMonthlyJpaRepository extends JpaRepository {
+
+ /**
+ * 주간 랭킹 MV 최대 버전을 조회한다.
+ *
+ * @param periodKey 기간 키
+ * @return 주간 랭킹 MV 최대 버전
+ */
+ @Query("select max(e.version) from MvProductRankMonthlyEntity e where e.periodKey = ?1")
+ Integer findMaxVersionByPeriodKey(String periodKey);
+
+ List findByPeriodKeyAndVersionOrderByRankValueAsc(String periodKey, int version);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java
new file mode 100644
index 0000000000..8cbe768000
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java
@@ -0,0 +1,53 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Table(
+ name = "mv_product_rank_weekly",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_mv_product_rank_weekly_period_product",
+ columnNames = {"period_key", "product_id"}
+ )
+)
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class MvProductRankWeeklyEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_key", nullable = false, length = 16)
+ private String periodKey;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "`rank`", nullable = false)
+ private int rankValue;
+
+ @Column(name = "score", nullable = false, precision = 24, scale = 8)
+ private BigDecimal score;
+
+ @Column(name = "version", nullable = false)
+ private int version;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java
new file mode 100644
index 0000000000..9a35748655
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java
@@ -0,0 +1,20 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface MvProductRankWeeklyJpaRepository extends JpaRepository {
+
+ /**
+ * 주간 랭킹 MV 최대 버전을 조회한다.
+ *
+ * @param periodKey 기간 키
+ * @return 주간 랭킹 MV 최대 버전
+ */
+ @Query("select max(e.version) from MvProductRankWeeklyEntity e where e.periodKey = ?1")
+ Integer findMaxVersionByPeriodKey(String periodKey);
+
+ List findByPeriodKeyAndVersionOrderByRankValueAsc(String periodKey, int version);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java
new file mode 100644
index 0000000000..599051d963
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java
@@ -0,0 +1,66 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.RankingMvReadRepository;
+import com.loopers.domain.ranking.RankingMvTableRow;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class RankingMvReadRepositoryImpl implements RankingMvReadRepository {
+
+ private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
+ private final MvProductRankMonthlyJpaRepository monthlyJpaRepository;
+
+ public RankingMvReadRepositoryImpl(
+ MvProductRankWeeklyJpaRepository weeklyJpaRepository,
+ MvProductRankMonthlyJpaRepository monthlyJpaRepository) {
+ this.weeklyJpaRepository = weeklyJpaRepository;
+ this.monthlyJpaRepository = monthlyJpaRepository;
+ }
+
+ /**
+ * 주간 랭킹 MV 최대 버전을 조회한다.
+ *
+ * @param periodKey 기간 키
+ * @return 주간 랭킹 MV 최대 버전
+ */
+ @Override
+ public Optional findMaxVersionForWeekly(String periodKey) {
+ return Optional.ofNullable(weeklyJpaRepository.findMaxVersionByPeriodKey(periodKey));
+ }
+
+ @Override
+ public Optional findMaxVersionForMonthly(String periodKey) {
+ return Optional.ofNullable(monthlyJpaRepository.findMaxVersionByPeriodKey(periodKey));
+ }
+
+ /**
+ * 주간 랭킹 MV를 조회한다.
+ *
+ * @param periodKey 기간 키
+ * @param version 버전
+ * @return 주간 랭킹 MV
+ */
+ @Override
+ public List findWeeklyByPeriodKeyAndVersionOrdered(String periodKey, int version) {
+ return weeklyJpaRepository.findByPeriodKeyAndVersionOrderByRankValueAsc(periodKey, version).stream()
+ .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore()))
+ .toList();
+ }
+
+ /**
+ * 월간 랭킹 MV를 조회한다.
+ *
+ * @param periodKey 기간 키
+ * @param version 버전
+ * @return 월간 랭킹 MV
+ */
+ @Override
+ public List findMonthlyByPeriodKeyAndVersionOrdered(String periodKey, int version) {
+ return monthlyJpaRepository.findByPeriodKeyAndVersionOrderByRankValueAsc(periodKey, version).stream()
+ .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore()))
+ .toList();
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
index 0b29176e34..753368bcc5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
@@ -8,25 +8,30 @@
import jakarta.validation.constraints.Min;
import org.springframework.http.ResponseEntity;
-@Tag(name = "Ranking V1 API", description = "일간 인기 상품 랭킹 API (비로그인 허용)")
+@Tag(name = "Ranking V1 API", description = "인기 상품 랭킹 API: 일간(Redis)·주간/월간(MV) (비로그인 허용)")
public interface RankingV1ApiSpec {
@Operation(
summary = "랭킹 목록 조회",
- description = "지정 일자의 일간 랭킹을 page·size 오프셋으로 조회합니다. Redis ZSET(ranking:all:{yyyyMMdd}) 점수 내림차순이며, "
- + "date 생략 시 오늘(Asia/Seoul)입니다. `rankingSnapshotId`를 주면 POST /rankings/snapshots로 만든 스냅샷 ZSET에서만 페이징해 순서가 고정됩니다. "
- + "라이브 조회는 실시간 갱신으로 재요청 시 항목이 달라질 수 있습니다. "
- + "응답 `dataSource`는 REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED를 구분합니다. "
+ description = "일간: Redis ZSET(ranking:all:{yyyyMMdd}) 점수 내림차순, date 생략 시 오늘(Asia/Seoul). "
+ + "`rankingSnapshotId`를 주면 POST /rankings/snapshots로 만든 스냅샷 ZSET에서만 페이징합니다. "
+ + "주간/월간: `period=WEEKLY|MONTHLY`와 `periodKey`(주간 yyyyWww, 월간 yyyyMM)를 함께 지정하면 DB MV에서 조회합니다(일간과 date·스냅샷과 동시 사용 불가). "
+ + "MV는 최대 100행 기준으로 total·페이징하며, 요청 page가 범위를 넘으면 빈 content와 total 유지. "
+ + "응답 `dataSource`: REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED·MV_WEEKLY·MV_MONTHLY. "
+ "동일 값을 헤더 `X-Loopers-Ranking-Data-Source`로도 내려줍니다."
)
ResponseEntity> getRankings(
- @Parameter(description = "기준 일자 yyyyMMdd (선택, 기본 오늘 Asia/Seoul)")
+ @Parameter(description = "기준 일자 yyyyMMdd (일간 전용, 선택, 기본 오늘 Asia/Seoul)")
String date,
+ @Parameter(description = "WEEKLY 또는 MONTHLY (주간/월간 MV, periodKey와 함께 지정)")
+ String period,
+ @Parameter(description = "주간 yyyyWww, 월간 yyyyMM")
+ String periodKey,
@Parameter(description = "페이지 번호 (1부터)")
@Min(1) int page,
@Parameter(description = "페이지 크기 (1~100)")
@Min(1) @Max(100) int size,
- @Parameter(description = "스냅샷 UUID (선택, POST /rankings/snapshots에서 발급)")
+ @Parameter(description = "스냅샷 UUID (일간 전용, POST /rankings/snapshots에서 발급)")
String rankingSnapshotId
);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
index 4e018bd405..d51c73fa1b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
@@ -44,12 +44,14 @@ public RankingV1Controller(RankingFacade rankingFacade) {
@Override
public ResponseEntity> getRankings(
@RequestParam(required = false) String date,
+ @RequestParam(required = false) String period,
+ @RequestParam(required = false) String periodKey,
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) String rankingSnapshotId
) {
Optional snap = Optional.ofNullable(rankingSnapshotId).filter(s -> !s.isBlank());
- RankingListInfo listResult = rankingFacade.getRankings(date, page, size, snap);
+ RankingListInfo listResult = rankingFacade.getRankings(date, period, periodKey, page, size, snap);
return ResponseEntity.ok()
.header(HEADER_RANKING_DATA_SOURCE, listResult.dataSource())
.body(ApiResponse.success(RankingV1Dto.ListResponse.from(listResult)));
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
index 1b8adaf2ea..826af97fa6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
@@ -69,10 +69,12 @@ public record ListResponse(
long totalElements,
@Schema(description = "총 페이지 수(ceil(totalElements/size), totalElements=0이면 0)")
int totalPages,
- @Schema(description = "REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED")
+ @Schema(description = "REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED·MV_WEEKLY·MV_MONTHLY")
String dataSource,
@Schema(description = "스냅샷 조회 시 echo, 라이브 조회면 null")
- String rankingSnapshotId
+ String rankingSnapshotId,
+ @Schema(description = "주간/월간 MV publish 버전(요청당 고정). 일간 조회면 null")
+ Integer mvPublishVersion
) {
/**
* 랭킹 목록 결과를 응답 DTO로 변환한다.
@@ -96,7 +98,8 @@ public static ListResponse from(RankingListInfo result) {
result.totalElements(),
result.totalPages(),
result.dataSource(),
- result.rankingSnapshotId()
+ result.rankingSnapshotId(),
+ result.mvPublishVersion()
);
}
}
diff --git a/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql b/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql
new file mode 100644
index 0000000000..69e60e468c
--- /dev/null
+++ b/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql
@@ -0,0 +1,26 @@
+-- Round 10: 주간/월간 랭킹 MV (commerce-api Flyway; 배치와 동일 스키마, CREATE IF NOT EXISTS로 공존)
+CREATE TABLE IF NOT EXISTS mv_product_rank_weekly (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ period_key VARCHAR(16) NOT NULL,
+ product_id BIGINT NOT NULL,
+ `rank` INT NOT NULL,
+ score DECIMAL(24, 8) NOT NULL,
+ version INT NOT NULL,
+ updated_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mv_product_rank_weekly_period_product (period_key, product_id),
+ KEY idx_mv_product_rank_weekly_period_rank (period_key, `rank`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS mv_product_rank_monthly (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ period_key VARCHAR(16) NOT NULL,
+ product_id BIGINT NOT NULL,
+ `rank` INT NOT NULL,
+ score DECIMAL(24, 8) NOT NULL,
+ version INT NOT NULL,
+ updated_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mv_product_rank_monthly_period_product (period_key, product_id),
+ KEY idx_mv_product_rank_monthly_period_rank (period_key, `rank`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
index e82a099b89..e0b51ef982 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
@@ -1,6 +1,7 @@
package com.loopers.application.ranking;
import com.loopers.domain.ranking.RankingListSource;
+import com.loopers.domain.ranking.RankingMvPeriod;
import com.loopers.domain.ranking.RankingPage;
import com.loopers.domain.ranking.RankingQueryService;
import com.loopers.domain.ranking.RankingRow;
@@ -16,6 +17,7 @@
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
+import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -45,6 +47,7 @@ void getRankings_shouldDelegateWithParsedDate() {
1L,
1,
RankingListSource.REDIS_ZSET,
+ null,
null
);
when(rankingQueryService.loadPage(eq(LocalDate.of(2026, 4, 8)), eq(1), eq(20), eq(Optional.empty())))
@@ -65,4 +68,46 @@ void getRankings_whenInvalidDate_shouldThrow() {
assertThatThrownBy(() -> rankingFacade.getRankings("bad", 1, 20))
.isInstanceOf(CoreException.class);
}
+
+ @Test
+ @DisplayName("period·periodKey가 있으면 loadMvPage로 위임한다")
+ void getRankings_whenWeeklyMv_shouldDelegateLoadMvPage() {
+ RankingPage domainPage = new RankingPage(
+ List.of(new RankingRow(1, 1L, 1.0d, "n", BigDecimal.ONE, 1L, "b", 0L, 0)),
+ 1,
+ 20,
+ 1L,
+ 1,
+ RankingListSource.MV_WEEKLY,
+ null,
+ 1
+ );
+ when(rankingQueryService.loadMvPage(RankingMvPeriod.WEEKLY, "2026W15", 1, 20))
+ .thenReturn(domainPage);
+
+ RankingListInfo out = rankingFacade.getRankings(
+ null, "WEEKLY", "2026W15", 1, 20, Optional.empty());
+
+ verify(rankingQueryService).loadMvPage(RankingMvPeriod.WEEKLY, "2026W15", 1, 20);
+
+ assertThat(out.dataSource()).isEqualTo("MV_WEEKLY");
+ assertThat(out.totalElements()).isEqualTo(1L);
+ assertThat(out.mvPublishVersion()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("date와 period를 동시에 주면 BAD_REQUEST")
+ void getRankings_whenDateAndPeriodTogether_shouldThrow() {
+ assertThatThrownBy(() -> rankingFacade.getRankings(
+ "20260408", "WEEKLY", "2026W15", 1, 20, Optional.empty()))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("주간 조회에 rankingSnapshotId를 주면 BAD_REQUEST")
+ void getRankings_whenMvAndSnapshot_shouldThrow() {
+ assertThatThrownBy(() -> rankingFacade.getRankings(
+ null, "WEEKLY", "2026W15", 1, 20, Optional.of(UUID.randomUUID().toString())))
+ .isInstanceOf(CoreException.class);
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java
new file mode 100644
index 0000000000..928a71ed47
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java
@@ -0,0 +1,86 @@
+package com.loopers.domain.ranking;
+
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RankingMvRequestValidatorTest {
+
+ @Test
+ @DisplayName("period만 있고 periodKey가 없으면 BAD_REQUEST")
+ void validateMvPairPresent_whenOnlyPeriod_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validateMvPairPresent(true, false))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("periodKey만 있으면 BAD_REQUEST")
+ void validateMvPairPresent_whenOnlyPeriodKey_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validateMvPairPresent(false, true))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("MV 요청과 date 동시 지정이면 BAD_REQUEST")
+ void validateMutualExclusion_whenBoth_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validateMutualExclusion(
+ Optional.of("20260408"), true))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("유효한 주간 periodKey는 통과")
+ void validatePeriodKey_weeklyOk() {
+ assertThatCode(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.WEEKLY, "2026W15"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("유효한 월간 periodKey는 통과")
+ void validatePeriodKey_monthlyOk() {
+ assertThatCode(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.MONTHLY, "202604"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("주간 periodKey가 yyyyWww 패턴이 아니면 BAD_REQUEST")
+ void validatePeriodKey_weeklyInvalidFormat_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.WEEKLY, "20260406"))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("주간 periodKey 주차가 01~53 밖이면 BAD_REQUEST")
+ void validatePeriodKey_weeklyOutOfRangeWeek_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.WEEKLY, "2026W00"))
+ .isInstanceOf(CoreException.class);
+ assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.WEEKLY, "2026W54"))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("월간 periodKey가 6자리 숫자가 아니면 BAD_REQUEST")
+ void validatePeriodKey_monthlyInvalidFormat_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.MONTHLY, "2026-04"))
+ .isInstanceOf(CoreException.class);
+ }
+
+ @Test
+ @DisplayName("월간 periodKey의 월이 01~12 밖이면 BAD_REQUEST")
+ void validatePeriodKey_monthlyInvalidMonth_shouldThrow() {
+ assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey(
+ RankingMvPeriod.MONTHLY, "202613"))
+ .isInstanceOf(CoreException.class);
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java
index 1bb9a4f650..da87b5fe28 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java
@@ -22,6 +22,7 @@
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -35,6 +36,9 @@
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -46,6 +50,9 @@ class RankingQueryServiceTest {
@Mock
private RankingSnapshotRepository rankingSnapshotRepository;
+ @Mock
+ private RankingMvReadRepository rankingMvReadRepository;
+
@Mock
private ProductRepository productRepository;
@@ -66,6 +73,7 @@ private RankingQueryService newService(boolean fallbackOnRedisFailure) {
return new RankingQueryService(
rankingReadRepository,
rankingSnapshotRepository,
+ rankingMvReadRepository,
productRepository,
brandService,
likeService,
@@ -603,4 +611,120 @@ void loadPage_withSnapshot_whenRedisCountFails_shouldNotFallbackToDb() {
assertThat(result.listSource()).isEqualTo(RankingListSource.DEGRADED_EMPTY);
assertThat(result.rows()).isEmpty();
}
+
+ @Test
+ @DisplayName("loadMvPage: 주간 MV 행 순서·Hydration")
+ void loadMvPage_weekly_shouldHydrateRows() {
+ when(rankingMvReadRepository.findMaxVersionForWeekly("2026W15")).thenReturn(Optional.of(1));
+ when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W15", 1))
+ .thenReturn(List.of(
+ new RankingMvTableRow(1, 101L, new BigDecimal("1.5")),
+ new RankingMvTableRow(2, 102L, new BigDecimal("0.5"))
+ ));
+
+ ProductModel p101 = mock(ProductModel.class);
+ when(p101.getBrandId()).thenReturn(1L);
+ when(p101.getName()).thenReturn("A");
+ when(p101.getPrice()).thenReturn(new BigDecimal("1000"));
+ when(p101.getStockQuantity()).thenReturn(3);
+ ProductModel p102 = mock(ProductModel.class);
+ when(p102.getBrandId()).thenReturn(1L);
+ when(p102.getName()).thenReturn("B");
+ when(p102.getPrice()).thenReturn(new BigDecimal("2000"));
+ when(p102.getStockQuantity()).thenReturn(0);
+ when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection()))
+ .thenReturn(Map.of(101L, p101, 102L, p102));
+ BrandModel brand = mock(BrandModel.class);
+ when(brand.getName()).thenReturn("브랜드");
+ when(brandService.findByIdAndNotDeletedIn(anyCollection())).thenReturn(Map.of(1L, brand));
+ when(likeService.getLikeCountByProductIdsFromStats(anyCollection()))
+ .thenReturn(Map.of(101L, 1L, 102L, 2L));
+
+ RankingPage result = rankingQueryService.loadMvPage(
+ RankingMvPeriod.WEEKLY, "2026W15", 1, 10);
+
+ assertThat(result.listSource()).isEqualTo(RankingListSource.MV_WEEKLY);
+ assertThat(result.totalElements()).isEqualTo(2L);
+ assertThat(result.rows()).hasSize(2);
+ assertThat(result.rows().get(0).rank()).isEqualTo(1);
+ assertThat(result.rows().get(0).productId()).isEqualTo(101L);
+ assertThat(result.rows().get(0).score()).isEqualTo(1.5d);
+ assertThat(result.rankingSnapshotId()).isNull();
+ assertThat(result.mvPublishVersion()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("loadMvPage: 조회 중 더 높은 버전이 생겨도 요청 시작 시 고정한 버전만 사용한다.")
+ void loadMvPage_whenHigherVersionExistsAfterFixedSelection_shouldUseOnlyFixedVersion() {
+ when(rankingMvReadRepository.findMaxVersionForWeekly("2026W30")).thenReturn(Optional.of(1));
+ when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 1))
+ .thenReturn(List.of(new RankingMvTableRow(1, 101L, BigDecimal.TEN)));
+
+ ProductModel p101 = mock(ProductModel.class);
+ when(p101.getBrandId()).thenReturn(1L);
+ when(p101.getName()).thenReturn("A");
+ when(p101.getPrice()).thenReturn(new BigDecimal("1000"));
+ when(p101.getStockQuantity()).thenReturn(1);
+ when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())).thenReturn(Map.of(101L, p101));
+ BrandModel brand = mock(BrandModel.class);
+ when(brand.getName()).thenReturn("브랜드");
+ when(brandService.findByIdAndNotDeletedIn(anyCollection())).thenReturn(Map.of(1L, brand));
+ when(likeService.getLikeCountByProductIdsFromStats(anyCollection())).thenReturn(Map.of());
+
+ RankingPage result = rankingQueryService.loadMvPage(
+ RankingMvPeriod.WEEKLY, "2026W30", 1, 20);
+
+ assertThat(result.mvPublishVersion()).isEqualTo(1);
+ assertThat(result.rows()).hasSize(1);
+ assertThat(result.rows().get(0).productId()).isEqualTo(101L);
+ verify(rankingMvReadRepository, times(1)).findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 1);
+ verify(rankingMvReadRepository, never()).findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 2);
+ }
+
+ @Test
+ @DisplayName("loadMvPage: page가 총 행을 넘으면 빈 목록·total 유지")
+ void loadMvPage_whenPageBeyond_shouldReturnEmptyWithTotal() {
+ when(rankingMvReadRepository.findMaxVersionForMonthly("202604")).thenReturn(Optional.of(1));
+ when(rankingMvReadRepository.findMonthlyByPeriodKeyAndVersionOrdered("202604", 1))
+ .thenReturn(List.of(new RankingMvTableRow(1, 1L, BigDecimal.ONE)));
+
+ RankingPage result = rankingQueryService.loadMvPage(
+ RankingMvPeriod.MONTHLY, "202604", 3, 1);
+
+ assertThat(result.listSource()).isEqualTo(RankingListSource.MV_MONTHLY);
+ assertThat(result.totalElements()).isEqualTo(1L);
+ assertThat(result.rows()).isEmpty();
+ assertThat(result.totalPages()).isEqualTo(1);
+ assertThat(result.mvPublishVersion()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("loadMvPage: 100행 초과 시 total은 100으로 캡")
+ void loadMvPage_whenMoreThan100Rows_shouldCapTotal() {
+ List many = new ArrayList<>();
+ for (int i = 1; i <= 105; i++) {
+ many.add(new RankingMvTableRow(i, (long) i, BigDecimal.valueOf(100 - i)));
+ }
+ when(rankingMvReadRepository.findMaxVersionForWeekly("2026W01")).thenReturn(Optional.of(2));
+ when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W01", 2)).thenReturn(many);
+ when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())).thenReturn(Map.of());
+
+ RankingPage result = rankingQueryService.loadMvPage(
+ RankingMvPeriod.WEEKLY, "2026W01", 1, 100);
+
+ assertThat(result.totalElements()).isEqualTo(100L);
+ assertThat(result.mvPublishVersion()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("loadMvPage: MAX(version)이 없으면 빈 페이지")
+ void loadMvPage_whenNoVersion_shouldReturnEmpty() {
+ when(rankingMvReadRepository.findMaxVersionForWeekly("2026W99")).thenReturn(Optional.empty());
+
+ RankingPage result = rankingQueryService.loadMvPage(
+ RankingMvPeriod.WEEKLY, "2026W99", 1, 10);
+
+ assertThat(result.totalElements()).isEqualTo(0L);
+ assertThat(result.mvPublishVersion()).isNull();
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java
index 3fcfb95cdd..83d624668b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java
@@ -21,9 +21,12 @@
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
import static com.loopers.interfaces.api.ApiResponse.Metadata.Result;
import static org.assertj.core.api.Assertions.assertThat;
@@ -54,6 +57,9 @@ class RankingV1ApiE2ETest {
@Autowired
private ProductService productService;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
private Long highScoreProductId;
private Long lowScoreProductId;
private String brandName;
@@ -375,4 +381,259 @@ void getRankings_withSnapshot_shouldKeepOrderWhenLiveZsetChanges() {
.isEqualTo(p3.getId())
);
}
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - 주간 MV: DB 행 순·Hydration·MV_WEEKLY")
+ void getRankings_weeklyMv_shouldReturnFromMaterializedView() {
+ seedTwoProductRanking();
+ String periodKey = "2026W15";
+ Instant at = Instant.now();
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ highScoreProductId,
+ 1,
+ new BigDecimal("0.90"),
+ 1,
+ Timestamp.from(at));
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ lowScoreProductId,
+ 2,
+ new BigDecimal("0.30"),
+ 1,
+ Timestamp.from(at));
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=1&size=20",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE))
+ .isEqualTo("MV_WEEKLY"),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_WEEKLY"),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2L),
+ () -> assertThat(response.getBody().data().content()).hasSize(2),
+ () -> assertThat(response.getBody().data().content().get(0).rank()).isEqualTo(1),
+ () -> assertThat(response.getBody().data().content().get(0).productId())
+ .isEqualTo(highScoreProductId),
+ () -> assertThat(response.getBody().data().content().get(0).score()).isEqualTo(0.9d),
+ () -> assertThat(response.getBody().data().content().get(1).productId())
+ .isEqualTo(lowScoreProductId),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1)
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - 월간 MV·MV_MONTHLY")
+ void getRankings_monthlyMv_shouldReturnFromMaterializedView() {
+ seedTwoProductRanking();
+ String periodKey = "202604";
+ Instant at = Instant.now();
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_monthly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ lowScoreProductId,
+ 1,
+ new BigDecimal("0.50"),
+ 1,
+ Timestamp.from(at));
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=MONTHLY&periodKey=" + periodKey + "&page=1&size=10",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_MONTHLY"),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L),
+ () -> assertThat(response.getBody().data().content()).hasSize(1),
+ () -> assertThat(response.getBody().data().content().get(0).productId())
+ .isEqualTo(lowScoreProductId),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1)
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - MV는 요청당 MAX(version)만 조회(혼합 버전 시 상위 버전만)")
+ void getRankings_weeklyMv_whenMixedVersions_shouldUseMaxVersionOnly() {
+ seedTwoProductRanking();
+ String periodKey = "2026W20";
+ Instant at = Instant.now();
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ highScoreProductId,
+ 1,
+ new BigDecimal("0.10"),
+ 1,
+ Timestamp.from(at));
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ lowScoreProductId,
+ 1,
+ new BigDecimal("0.99"),
+ 2,
+ Timestamp.from(at));
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=1&size=20",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(2),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L),
+ () -> assertThat(response.getBody().data().content()).hasSize(1),
+ () -> assertThat(response.getBody().data().content().get(0).productId())
+ .isEqualTo(lowScoreProductId),
+ () -> assertThat(response.getBody().data().content().get(0).score()).isEqualTo(0.99d)
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - MV에서 마지막 페이지 초과 시 빈 content·total 유지")
+ void getRankings_weeklyMv_whenPageBeyond_shouldKeepTotal() {
+ seedTwoProductRanking();
+ String periodKey = "2026W16";
+ Instant at = Instant.now();
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ highScoreProductId,
+ 1,
+ BigDecimal.ONE,
+ 1,
+ Timestamp.from(at));
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=5&size=1",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().content()).isEmpty(),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L),
+ () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1)
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - 월간 MV에서 마지막 페이지 초과 시 빈 content·total 유지")
+ void getRankings_monthlyMv_whenPageBeyond_shouldKeepTotal() {
+ seedTwoProductRanking();
+ String periodKey = "202605";
+ Instant at = Instant.now();
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_monthly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ highScoreProductId,
+ 1,
+ BigDecimal.ONE,
+ 1,
+ Timestamp.from(at));
+
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=MONTHLY&periodKey=" + periodKey + "&page=5&size=1",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().content()).isEmpty(),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L),
+ () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1)
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - 주간 MV 버전이 없으면 빈 content와 mvPublishVersion null")
+ void getRankings_weeklyMv_whenNoPublishedVersion_shouldReturnEmptyWithNullVersion() {
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=WEEKLY&periodKey=2026W53&page=1&size=20",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_WEEKLY"),
+ () -> assertThat(response.getBody().data().content()).isEmpty(),
+ () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0L),
+ () -> assertThat(response.getBody().data().mvPublishVersion()).isNull()
+ );
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - period만 주면 400")
+ void getRankings_whenPeriodWithoutKey_shouldReturn400() {
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?period=WEEKLY&page=1&size=20",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL);
+ }
+
+ @Test
+ @DisplayName("GET /api/v1/rankings - date와 period 동시 지정 시 400")
+ void getRankings_whenDateWithMvPeriod_shouldReturn400() {
+ ResponseEntity> response = testRestTemplate.exchange(
+ ENDPOINT + "?date=20260408&period=WEEKLY&periodKey=2026W15&page=1&size=20",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<>() {});
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java
index 9306eb2c72..ecf1130178 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java
@@ -36,12 +36,13 @@ void getRankings_whenFallbackLatest_shouldExposeSameDataSourceInHeaderAndBody()
0L,
0,
"FALLBACK_LATEST",
+ null,
null
);
- when(rankingFacade.getRankings("20260408", 1, 20, Optional.empty())).thenReturn(fallback);
+ when(rankingFacade.getRankings("20260408", null, null, 1, 20, Optional.empty())).thenReturn(fallback);
ResponseEntity> response =
- rankingV1Controller.getRankings("20260408", 1, 20, null);
+ rankingV1Controller.getRankings("20260408", null, null, 1, 20, null);
assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE))
.isEqualTo("FALLBACK_LATEST");
@@ -59,12 +60,13 @@ void getRankings_whenDegraded_shouldExposeSameDataSourceInHeaderAndBody() {
0L,
0,
"DEGRADED",
+ null,
null
);
- when(rankingFacade.getRankings(null, 1, 20, Optional.empty())).thenReturn(degraded);
+ when(rankingFacade.getRankings(null, null, null, 1, 20, Optional.empty())).thenReturn(degraded);
ResponseEntity> response =
- rankingV1Controller.getRankings(null, 1, 20, null);
+ rankingV1Controller.getRankings(null, null, null, 1, 20, null);
assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE))
.isEqualTo("DEGRADED");
diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts
index 205bedfbef..0a5b4ffad5 100644
--- a/apps/commerce-batch/build.gradle.kts
+++ b/apps/commerce-batch/build.gradle.kts
@@ -9,6 +9,8 @@ dependencies {
// batch
implementation("org.springframework.boot:spring-boot-starter-batch")
+ runtimeOnly("org.flywaydb:flyway-core")
+ runtimeOnly("org.flywaydb:flyway-mysql")
testImplementation("org.springframework.batch:spring-batch-test")
testImplementation("org.springframework.kafka:spring-kafka-test")
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java
new file mode 100644
index 0000000000..7dbd16c1a5
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java
@@ -0,0 +1,55 @@
+package com.loopers.batch.ranking;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 주간/월간 랭킹 집계에 사용하는 기간 키를 생성한다.
+ *
+ * 이전 라운드와 동일하게 {@code yyyyMMdd} 형식을 사용하며,
+ * 주간/월간은 각각 대표 일자(앵커 날짜)를 {@code yyyyMMdd}로 표현한다.
+ */
+public final class RankingPeriodKey {
+
+ private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
+
+ private RankingPeriodKey() {
+ }
+
+ /**
+ * 주간 랭킹 기간 키를 생성한다.
+ *
+ * ISO-8601 week 규칙으로 같은 주에 속하는 날짜들은 모두 동일한 대표 일자(예: 해당 주의 월요일)로 매핑되고,
+ * 반환 형식은 {@code yyyyMMdd}이다.
+ *
+ * @param date 기간에 속한 임의의 날짜 (Asia/Seoul 달력 기준으로 계산된 일자)
+ * @return 예: {@code 20260406} (2026년 4월 6일이 속한 주의 대표 일자)
+ * @throws IllegalArgumentException date가 null인 경우
+ */
+ public static String weekly(LocalDate date) {
+ if (date == null) {
+ throw new IllegalArgumentException("date must not be null");
+ }
+ // ISO 기준으로 같은 주에 속하는 날짜들이 모두 같은 월요일 앵커로 귀속되도록 한다.
+ LocalDate anchor = date.with(java.time.DayOfWeek.MONDAY);
+ return DATE.format(anchor);
+ }
+
+ /**
+ * 월간 랭킹 기간 키를 생성한다.
+ *
+ * 반환 형식은 {@code yyyyMMdd}이며, 해당 월의 첫째 날을 대표 일자로 사용한다.
+ *
+ * @param date 기간에 속한 임의의 날짜 (Asia/Seoul 달력 기준으로 계산된 일자)
+ * @return 예: {@code 20260401}
+ * @throws IllegalArgumentException date가 null인 경우
+ */
+ public static String monthly(LocalDate date) {
+ if (date == null) {
+ throw new IllegalArgumentException("date must not be null");
+ }
+ LocalDate firstDayOfMonth = date.withDayOfMonth(1);
+ return DATE.format(firstDayOfMonth);
+ }
+}
+
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java
new file mode 100644
index 0000000000..f484afc2dc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java
@@ -0,0 +1,476 @@
+package com.loopers.batch.ranking.job;
+
+import com.loopers.batch.listener.JobListener;
+import com.loopers.batch.listener.StepMonitorListener;
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import com.loopers.domain.ranking.batch.RankingMvScoreCalculator;
+import com.loopers.domain.ranking.batch.RankingScoreCandidate;
+import com.loopers.domain.ranking.batch.RankingStagingRankRow;
+import com.loopers.domain.ranking.batch.RankingStagingRepository;
+import com.loopers.domain.ranking.batch.RankingStagingSnapshotValidator;
+import com.loopers.domain.ranking.batch.RankingTop100Accumulator;
+import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository;
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+import com.loopers.infrastructure.ranking.batch.ProductMetricsEntity;
+import com.loopers.infrastructure.ranking.batch.RedisRankingBatchLock;
+import jakarta.persistence.EntityManagerFactory;
+import org.springframework.batch.core.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersInvalidException;
+import org.springframework.batch.core.JobParametersValidator;
+import org.springframework.batch.core.Step;
+import org.springframework.batch.core.StepExecution;
+import org.springframework.batch.core.StepExecutionListener;
+import org.springframework.batch.core.UnexpectedJobExecutionException;
+import org.springframework.batch.core.configuration.annotation.StepScope;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.launch.support.RunIdIncrementer;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.step.builder.StepBuilder;
+import org.springframework.batch.core.step.tasklet.Tasklet;
+import org.springframework.batch.item.ItemProcessor;
+import org.springframework.batch.item.ItemWriter;
+import org.springframework.batch.item.database.JpaPagingItemReader;
+import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
+import org.springframework.batch.repeat.RepeatStatus;
+import org.springframework.batch.support.transaction.ResourcelessTransactionManager;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Round 10 — 랭킹 MV 배치 Job(3단계): 파라미터 검증 → period 락 → staging 정리(자리) → 집계(자리) → publish(자리).
+ */
+@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingBatchJobParameters.JOB_NAME)
+@Configuration
+public class RankingBatchJobConfig {
+
+ public static final String JOB_NAME = RankingBatchJobParameters.JOB_NAME;
+
+ private static final String STEP_PERIOD_LOCK = "rankingPeriodLock";
+ private static final String STEP_STAGING_CLEANUP = "rankingStagingCleanup";
+ private static final String STEP_AGGREGATE = "rankingAggregate";
+ private static final String STEP_PUBLISH = "rankingPublish";
+
+ private final ObjectProvider jobRepositoryProvider;
+ private final JobListener jobListener;
+ private final StepMonitorListener stepMonitorListener;
+ private final PlatformTransactionManager transactionManager;
+
+ public RankingBatchJobConfig(
+ ObjectProvider jobRepositoryProvider,
+ JobListener jobListener,
+ StepMonitorListener stepMonitorListener,
+ @Lazy PlatformTransactionManager transactionManager
+ ) {
+ this.jobRepositoryProvider = jobRepositoryProvider;
+ this.jobListener = jobListener;
+ this.stepMonitorListener = stepMonitorListener;
+ this.transactionManager = transactionManager;
+ }
+
+ private JobRepository jobRepository() {
+ return jobRepositoryProvider.getObject();
+ }
+
+ /**
+ * Redis 랭킹 배치 락을 생성한다.
+ *
+ * @param redisTemplate RedisTemplate
+ * @return RedisRankingBatchLock
+ */
+ @Bean
+ public RedisRankingBatchLock rankingBatchLock(
+ @Qualifier("redisTemplateMaster") RedisTemplate redisTemplate
+ ) {
+ return new RedisRankingBatchLock(redisTemplate);
+ }
+
+ /**
+ * 랭킹 배치 파라미터 검증을 생성한다.
+ *
+ * @return JobParametersValidator
+ */
+ @Bean
+ public JobParametersValidator rankingJobParametersValidator() {
+ return new JobParametersValidator() {
+ @Override
+ public void validate(JobParameters parameters) throws JobParametersInvalidException {
+ String period = parameters.getString(RankingBatchJobParameters.JOB_PARAM_PERIOD);
+ String periodKey = parameters.getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY);
+ if (period == null || period.isBlank()) {
+ throw new JobParametersInvalidException(
+ "Job 파라미터 period는 필수입니다. (WEEKLY 또는 MONTHLY)"
+ );
+ }
+ if (periodKey == null || periodKey.isBlank()) {
+ throw new JobParametersInvalidException("Job 파라미터 periodKey는 필수입니다.");
+ }
+ try {
+ RankingBatchJobParameters.validate(period, periodKey);
+ } catch (IllegalArgumentException e) {
+ throw new JobParametersInvalidException(e.getMessage());
+ }
+ }
+ };
+ }
+
+ /**
+ * 랭킹 배치 락 해제를 생성한다.
+ *
+ * @param lock RedisRankingBatchLock
+ * @return JobExecutionListener
+ */
+ @Bean("rankingBatchLockReleaseListener")
+ public JobExecutionListener rankingBatchLockReleaseListener(RedisRankingBatchLock lock) {
+ return new JobExecutionListener() {
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ }
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ var ctx = jobExecution.getExecutionContext();
+ Object lockHeld = ctx.get(RankingBatchJobParameters.CTX_LOCK_HELD);
+ if (!"true".equals(lockHeld instanceof String ? (String) lockHeld : null)) {
+ return;
+ }
+ String period = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD);
+ String periodKey = jobExecution.getJobParameters()
+ .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY);
+ Object ownerObj = ctx.get(RankingBatchJobParameters.CTX_LOCK_OWNER);
+ String owner = ownerObj instanceof String ? (String) ownerObj : null;
+ if (period != null && periodKey != null && owner != null) {
+ lock.releaseIfHeld(period, periodKey, owner);
+ }
+ ctx.remove(RankingBatchJobParameters.CTX_LOCK_HELD);
+ ctx.remove(RankingBatchJobParameters.CTX_LOCK_OWNER);
+ }
+ };
+ }
+
+ /**
+ * 랭킹 배치 락 획득을 생성한다.
+ *
+ * @param rankingBatchLock RedisRankingBatchLock
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @return Tasklet
+ */
+ @Bean
+ @StepScope
+ public Tasklet rankingPeriodLockTasklet(
+ RedisRankingBatchLock rankingBatchLock,
+ @Value("#{jobParameters['period']}") String period,
+ @Value("#{jobParameters['periodKey']}") String periodKey
+ ) {
+ return (contribution, chunkContext) -> {
+ long jobExecutionId = contribution.getStepExecution().getJobExecution().getId();
+ String ownerToken = String.valueOf(jobExecutionId);
+ if (!rankingBatchLock.tryAcquire(period, periodKey, ownerToken)) {
+ throw new UnexpectedJobExecutionException(
+ "period 락을 획득하지 못했습니다. 다른 실행이 동일 period를 처리 중일 수 있습니다: "
+ + period + ":" + periodKey
+ );
+ }
+ var jobCtx = contribution.getStepExecution().getJobExecution().getExecutionContext();
+ jobCtx.putString(RankingBatchJobParameters.CTX_LOCK_HELD, "true");
+ jobCtx.putString(RankingBatchJobParameters.CTX_LOCK_OWNER, ownerToken);
+ return RepeatStatus.FINISHED;
+ };
+ }
+
+ /**
+ * 랭킹 배치 스테이징 정리를 생성한다.
+ *
+ * @param rankingStagingRepository RankingStagingRepository
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @return Tasklet
+ */
+ @Bean
+ @StepScope
+ public Tasklet rankingStagingCleanupTasklet(
+ RankingStagingRepository rankingStagingRepository,
+ @Value("#{jobParameters['period']}") String period,
+ @Value("#{jobParameters['periodKey']}") String periodKey
+ ) {
+ return (contribution, chunkContext) -> {
+ rankingStagingRepository.deleteByPeriodTypeAndPeriodKey(period, periodKey);
+ return RepeatStatus.FINISHED;
+ };
+ }
+
+ /**
+ * 랭킹 배치 상품 메트릭스 리더를 생성한다.
+ *
+ * @param entityManagerFactory EntityManagerFactory
+ * @return JpaPagingItemReader
+ */
+ @Bean
+ @StepScope
+ public JpaPagingItemReader rankingProductMetricsReader(
+ EntityManagerFactory entityManagerFactory
+ ) throws Exception {
+ JpaPagingItemReader reader = new JpaPagingItemReaderBuilder()
+ .name("rankingProductMetricsReader")
+ .entityManagerFactory(entityManagerFactory)
+ .pageSize(50)
+ .queryString("select e from ProductMetricsEntity e order by e.productId asc")
+ .build();
+ reader.afterPropertiesSet();
+ return reader;
+ }
+
+ /**
+ * 랭킹 배치 상품 메트릭스 프로세서를 생성한다.
+ *
+ * @return ItemProcessor
+ */
+ @Bean
+ @StepScope
+ public ItemProcessor rankingAggregateProcessor() {
+ return entity -> new RankingScoreCandidate(
+ entity.getProductId(),
+ RankingMvScoreCalculator.score(
+ entity.getViewCount(),
+ entity.getLikeCount(),
+ entity.getSoldQuantity()
+ )
+ );
+ }
+
+ /**
+ * 랭킹 배치 집계 누적기를 생성한다.
+ *
+ * @return RankingTop100Accumulator
+ */
+ @Bean
+ @StepScope
+ public RankingTop100Accumulator rankingTop100Accumulator() {
+ return new RankingTop100Accumulator();
+ }
+
+ /**
+ * 랭킹 배치 집계 히프 라이터를 생성한다.
+ *
+ * @param rankingTop100Accumulator RankingTop100Accumulator
+ * @return ItemWriter
+ */
+ @Bean
+ @StepScope
+ public ItemWriter rankingAggregateHeapWriter(
+ RankingTop100Accumulator rankingTop100Accumulator
+ ) {
+ return chunk -> {
+ for (RankingScoreCandidate candidate : chunk.getItems()) {
+ rankingTop100Accumulator.accept(candidate);
+ }
+ };
+ }
+
+ /**
+ * 랭킹 배치 집계 플러시 리스너를 생성한다.
+ *
+ * @param rankingTop100Accumulator RankingTop100Accumulator
+ * @param rankingStagingRepository RankingStagingRepository
+ * @return StepExecutionListener
+ */
+ @Bean
+ @StepScope
+ public StepExecutionListener rankingAggregateFlushListener(
+ RankingTop100Accumulator rankingTop100Accumulator,
+ RankingStagingRepository rankingStagingRepository
+ ) {
+ return new StepExecutionListener() {
+ @Override
+ public void beforeStep(StepExecution stepExecution) {
+ }
+
+ @Override
+ public ExitStatus afterStep(StepExecution stepExecution) {
+ if (!ExitStatus.COMPLETED.equals(stepExecution.getExitStatus())) {
+ return stepExecution.getExitStatus();
+ }
+ String periodType = stepExecution.getJobParameters()
+ .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD);
+ String periodKey = stepExecution.getJobParameters()
+ .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY);
+ if (periodType == null || periodKey == null) {
+ return ExitStatus.FAILED;
+ }
+ rankingStagingRepository.saveRankedRows(
+ periodType,
+ periodKey,
+ rankingTop100Accumulator.toSortedRankRows()
+ );
+ return ExitStatus.COMPLETED;
+ }
+ };
+ }
+
+ /**
+ * 랭킹 배치 발행을 생성한다.
+ *
+ * @param rankingStagingRepository RankingStagingRepository
+ * @param productRankMvPublishRepository ProductRankMvPublishRepository
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @return Tasklet
+ */
+ @Bean
+ @StepScope
+ public Tasklet rankingPublishTasklet(
+ RankingStagingRepository rankingStagingRepository,
+ ProductRankMvPublishRepository productRankMvPublishRepository,
+ @Value("#{jobParameters['period']}") String period,
+ @Value("#{jobParameters['periodKey']}") String periodKey
+ ) {
+ return (contribution, chunkContext) -> {
+ List staged = rankingStagingRepository.findRankedRows(period, periodKey);
+ try {
+ RankingStagingSnapshotValidator.validateOrThrow(staged);
+ } catch (IllegalArgumentException e) {
+ throw new UnexpectedJobExecutionException("스테이징 검증 실패: " + e.getMessage(), e);
+ }
+ Instant publishedAt = Instant.now();
+ int snapshotVersion = 1;
+ List mvRows = staged.stream()
+ .map(r -> ProductRankMvRow.newRow(
+ periodKey,
+ r.productId(),
+ r.rank(),
+ r.score(),
+ snapshotVersion,
+ publishedAt
+ ))
+ .toList();
+ RankingBatchJobParameters.Period p = RankingBatchJobParameters.Period.parse(period);
+ switch (p) {
+ case WEEKLY -> productRankMvPublishRepository.replaceWeeklyPeriod(periodKey, mvRows, publishedAt);
+ case MONTHLY -> productRankMvPublishRepository.replaceMonthlyPeriod(periodKey, mvRows, publishedAt);
+ }
+ return RepeatStatus.FINISHED;
+ };
+ }
+
+ /**
+ * 랭킹 배치 Job을 생성한다.
+ *
+ * @param rankingJobParametersValidator JobParametersValidator
+ * @param rankingBatchLockReleaseListener JobExecutionListener
+ * @param periodLockStep Step
+ * @param stagingCleanupStep Step
+ * @param aggregateStep Step
+ * @param publishStep Step
+ * @return Job
+ */
+ @Bean(JOB_NAME)
+ public Job rankingProductMvJob(
+ JobParametersValidator rankingJobParametersValidator,
+ @Qualifier("rankingBatchLockReleaseListener") JobExecutionListener rankingBatchLockReleaseListener,
+ @Qualifier("rankingBatchJobMetricsListener") JobExecutionListener rankingBatchJobMetricsListener,
+ @Qualifier(STEP_PERIOD_LOCK) Step periodLockStep,
+ @Qualifier(STEP_STAGING_CLEANUP) Step stagingCleanupStep,
+ @Qualifier(STEP_AGGREGATE) Step aggregateStep,
+ @Qualifier(STEP_PUBLISH) Step publishStep
+ ) {
+ return new JobBuilder(JOB_NAME, jobRepository())
+ .incrementer(new RunIdIncrementer())
+ .validator(rankingJobParametersValidator)
+ .listener(rankingBatchLockReleaseListener)
+ .listener(rankingBatchJobMetricsListener)
+ .listener(jobListener)
+ .start(periodLockStep)
+ .next(stagingCleanupStep)
+ .next(aggregateStep)
+ .next(publishStep)
+ .build();
+ }
+
+ /**
+ * 랭킹 배치 락 획득을 생성한다.
+ *
+ * @param rankingPeriodLockTasklet Tasklet
+ * @return Step
+ */
+ @Bean(STEP_PERIOD_LOCK)
+ public Step periodLockStep(Tasklet rankingPeriodLockTasklet) {
+ return new StepBuilder(STEP_PERIOD_LOCK, jobRepository())
+ .tasklet(rankingPeriodLockTasklet, new ResourcelessTransactionManager())
+ .listener(stepMonitorListener)
+ .build();
+ }
+
+ /**
+ * 랭킹 배치 스테이징 정리를 생성한다.
+ *
+ * @return Step
+ */
+ @Bean(STEP_STAGING_CLEANUP)
+ public Step stagingCleanupStep(
+ PlatformTransactionManager transactionManager,
+ Tasklet rankingStagingCleanupTasklet
+ ) {
+ return new StepBuilder(STEP_STAGING_CLEANUP, jobRepository())
+ .tasklet(rankingStagingCleanupTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+
+ /**
+ * 랭킹 배치 집계를 생성한다.
+ *
+ * @param rankingProductMetricsReader JpaPagingItemReader
+ * @param rankingAggregateProcessor ItemProcessor
+ * @param rankingAggregateHeapWriter ItemWriter
+ * @param rankingAggregateFlushListener StepExecutionListener
+ * @return Step
+ */
+ @Bean(STEP_AGGREGATE)
+ public Step aggregateStep(
+ JpaPagingItemReader rankingProductMetricsReader,
+ ItemProcessor rankingAggregateProcessor,
+ ItemWriter rankingAggregateHeapWriter,
+ StepExecutionListener rankingAggregateFlushListener
+ ) {
+ return new StepBuilder(STEP_AGGREGATE, jobRepository())
+ .chunk(50, transactionManager)
+ .reader(rankingProductMetricsReader)
+ .processor(rankingAggregateProcessor)
+ .writer(rankingAggregateHeapWriter)
+ .listener(rankingAggregateFlushListener)
+ .listener(stepMonitorListener)
+ .build();
+ }
+
+ /**
+ * 랭킹 배치 발행을 생성한다.
+ *
+ * @param transactionManager PlatformTransactionManager
+ * @param rankingPublishTasklet Tasklet
+ * @return Step
+ */
+ @Bean(STEP_PUBLISH)
+ public Step publishStep(
+ PlatformTransactionManager transactionManager,
+ Tasklet rankingPublishTasklet
+ ) {
+ return new StepBuilder(STEP_PUBLISH, jobRepository())
+ .tasklet(rankingPublishTasklet, transactionManager)
+ .listener(stepMonitorListener)
+ .build();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java
new file mode 100644
index 0000000000..9968b6b18d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java
@@ -0,0 +1,72 @@
+package com.loopers.batch.ranking.metrics;
+
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tags;
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 랭킹 MV 배치 성공 시각·스냅샷 노후화(초) 게이지. 로드맵 3.7.
+ */
+@Component
+public class RankingBatchJobMetrics {
+
+ private final MeterRegistry meterRegistry;
+ private final ConcurrentHashMap lastSuccessEpochSeconds = new ConcurrentHashMap<>();
+
+ public RankingBatchJobMetrics(MeterRegistry meterRegistry) {
+ this.meterRegistry = meterRegistry;
+ }
+
+ /**
+ * Job이 COMPLETED일 때 period·periodKey별 마지막 성공 시각(epoch 초)을 갱신한다.
+ *
+ * @param period WEEKLY / MONTHLY
+ * @param periodKey 주간·월간 키
+ */
+ public void recordSuccess(String period, String periodKey) {
+ String key = mapKey(period, periodKey);
+ AtomicLong holder = lastSuccessEpochSeconds.computeIfAbsent(key, k -> registerGauges(period, periodKey));
+ holder.set(Instant.now().getEpochSecond());
+ }
+
+ /**
+ * 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지를 등록한다.
+ *
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @return 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지
+ */
+ private AtomicLong registerGauges(String period, String periodKey) {
+ AtomicLong holder = new AtomicLong(0L);
+ Tags tags = Tags.of("period", period, "period_key", periodKey);
+ Gauge.builder("batch.rank.job.last.success.epoch", holder, h -> (double) h.get())
+ .tags(tags)
+ .register(meterRegistry);
+ Gauge.builder("batch.rank.snapshot.stale.seconds", holder, RankingBatchJobMetrics::staleSeconds)
+ .tags(tags)
+ .register(meterRegistry);
+ return holder;
+ }
+
+ /**
+ * 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지를 계산한다.
+ * @param lastSuccessEpoch
+ * @return
+ */
+ private static double staleSeconds(AtomicLong lastSuccessEpoch) {
+ long t = lastSuccessEpoch.get();
+ if (t <= 0L) {
+ return -1d;
+ }
+ return (double) (Instant.now().getEpochSecond() - t);
+ }
+
+ private static String mapKey(String period, String periodKey) {
+ return period + "|" + periodKey;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java
new file mode 100644
index 0000000000..4e1d7f70c4
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java
@@ -0,0 +1,69 @@
+package com.loopers.batch.ranking.metrics;
+
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+import org.springframework.stereotype.Component;
+
+/**
+ * 랭킹 MV 배치 실패 카운트·성공 시각 메트릭. 로드맵 3.7.
+ */
+@Component("rankingBatchJobMetricsListener")
+public class RankingBatchJobMetricsListener implements JobExecutionListener {
+
+ private final MeterRegistry meterRegistry;
+ private final RankingBatchJobMetrics rankingBatchJobMetrics;
+
+ /**
+ * 랭킹 배치 실패 카운트·성공 시각 메트릭 리스너를 생성한다.
+ *
+ * @param meterRegistry MeterRegistry
+ * @param rankingBatchJobMetrics RankingBatchJobMetrics
+ */
+ public RankingBatchJobMetricsListener(
+ MeterRegistry meterRegistry,
+ RankingBatchJobMetrics rankingBatchJobMetrics) {
+ this.meterRegistry = meterRegistry;
+ this.rankingBatchJobMetrics = rankingBatchJobMetrics;
+ }
+
+ /**
+ * 랭킹 배치 실행 전 작업을 수행한다.
+ *
+ * @param jobExecution JobExecution
+ */
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ }
+
+ /**
+ * 랭킹 배치 실행 후 작업을 수행한다.
+ *
+ * @param jobExecution JobExecution
+ */
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ if (!RankingBatchJobParameters.JOB_NAME.equals(jobExecution.getJobInstance().getJobName())) {
+ return;
+ }
+ String period = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD);
+ String periodKey = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY);
+ if (period == null || periodKey == null) {
+ return;
+ }
+ BatchStatus status = jobExecution.getStatus();
+ if (status == BatchStatus.COMPLETED) {
+ rankingBatchJobMetrics.recordSuccess(period, periodKey);
+ return;
+ }
+ if (status.isUnsuccessful()) {
+ Counter.builder("batch.rank.job.failure.count")
+ .tags("period", period, "period_key", periodKey, "batch_status", status.name())
+ .register(meterRegistry)
+ .increment();
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java
new file mode 100644
index 0000000000..78fbe8d559
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java
@@ -0,0 +1,96 @@
+package com.loopers.domain.ranking.batch;
+
+import java.util.regex.Pattern;
+
+/**
+ * 랭킹 MV 배치 Job 파라미터 이름·값 검증·락 키 규칙을 한곳에 둔다.
+ */
+public final class RankingBatchJobParameters {
+
+ public static final String JOB_NAME = "rankingProductMvJob";
+ public static final String JOB_PARAM_PERIOD = "period";
+ public static final String JOB_PARAM_PERIOD_KEY = "periodKey";
+
+ public static final String CTX_LOCK_HELD = "ranking.batch.lockHeld";
+ public static final String CTX_LOCK_OWNER = "ranking.batch.lockOwnerToken";
+
+ private static final Pattern WEEKLY_KEY = Pattern.compile("^\\d{4}W\\d{2}$");
+ private static final Pattern MONTHLY_KEY = Pattern.compile("^\\d{6}$");
+
+ private RankingBatchJobParameters() {
+ }
+
+ /**
+ * 기간 타입.
+ */
+ public enum Period {
+ WEEKLY,
+ MONTHLY;
+
+ public static Period parse(String raw) {
+ if (raw == null || raw.isBlank()) {
+ throw new IllegalArgumentException("period는 필수이며 비어 있으면 안 됩니다.");
+ }
+ return Period.valueOf(raw.trim());
+ }
+ }
+
+ /**
+ * JobParameters 검증과 동일한 규칙으로 period·periodKey를 검사한다.
+ */
+ public static void validate(String periodRaw, String periodKeyRaw) {
+ if (periodKeyRaw == null || periodKeyRaw.isBlank()) {
+ throw new IllegalArgumentException("periodKey는 필수이며 비어 있으면 안 됩니다.");
+ }
+ Period period = Period.parse(periodRaw);
+ switch (period) {
+ case WEEKLY -> validateWeeklyKey(periodKeyRaw);
+ case MONTHLY -> validateMonthlyKey(periodKeyRaw);
+ }
+ }
+
+ /**
+ * Redis 락 키를 생성한다.
+ *
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @return Redis 락 키
+ */
+ public static String redisLockKey(String period, String periodKey) {
+ return "batch:rank:lock:" + period + ":" + periodKey;
+ }
+
+ /**
+ * 주간 키를 검증한다.
+ *
+ * @param periodKey 기간 키
+ */
+ private static void validateWeeklyKey(String periodKey) {
+ if (!WEEKLY_KEY.matcher(periodKey).matches()) {
+ throw new IllegalArgumentException(
+ "period가 WEEKLY일 때 periodKey는 yyyyWww 형식이어야 합니다. 예: 2026W15"
+ );
+ }
+ int week = Integer.parseInt(periodKey.substring(periodKey.indexOf('W') + 1));
+ if (week < 1 || week > 53) {
+ throw new IllegalArgumentException("주차는 01~53 범위여야 합니다.");
+ }
+ }
+
+ /**
+ * 월간 키를 검증한다.
+ *
+ * @param periodKey 기간 키
+ */
+ private static void validateMonthlyKey(String periodKey) {
+ if (!MONTHLY_KEY.matcher(periodKey).matches()) {
+ throw new IllegalArgumentException(
+ "period가 MONTHLY일 때 periodKey는 yyyyMM 형식이어야 합니다. 예: 202604"
+ );
+ }
+ int month = Integer.parseInt(periodKey.substring(4, 6));
+ if (month < 1 || month > 12) {
+ throw new IllegalArgumentException("월은 01~12 범위여야 합니다.");
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java
new file mode 100644
index 0000000000..c27c52276f
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java
@@ -0,0 +1,28 @@
+package com.loopers.domain.ranking.batch;
+
+/**
+ * 스트리머 {@code RankingScoreCalculator}와 동일한 기본 가중치(0.1 / 0.2 / 0.6)로 점수를 계산한다.
+ * commerce-streamer 모듈에 의존하지 않기 위해 배치 도메인에 둔다.
+ */
+public final class RankingMvScoreCalculator {
+
+ private static final double VIEW_WEIGHT = 0.1d;
+ private static final double LIKE_WEIGHT = 0.2d;
+ private static final double ORDER_WEIGHT = 0.6d;
+
+ private RankingMvScoreCalculator() {
+ }
+
+ /**
+ * @param viewCount 조회 수(비음수)
+ * @param likeCount 좋아요 수(비음수)
+ * @param soldQuantity 판매 수량(비음수)
+ * @return 가중 합산 점수
+ */
+ public static double score(long viewCount, long likeCount, long soldQuantity) {
+ if (viewCount < 0L || likeCount < 0L || soldQuantity < 0L) {
+ throw new IllegalArgumentException("metric counts must be non-negative");
+ }
+ return VIEW_WEIGHT * viewCount + LIKE_WEIGHT * likeCount + ORDER_WEIGHT * soldQuantity;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java
new file mode 100644
index 0000000000..ad96d34d92
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java
@@ -0,0 +1,7 @@
+package com.loopers.domain.ranking.batch;
+
+/**
+ * 집계 단계에서 힙에 넣는 상품·점수 후보.
+ */
+public record RankingScoreCandidate(long productId, double score) {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java
new file mode 100644
index 0000000000..08dcd44abc
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java
@@ -0,0 +1,9 @@
+package com.loopers.domain.ranking.batch;
+
+import java.math.BigDecimal;
+
+/**
+ * 스테이징 테이블에 저장할 순위 확정 행.
+ */
+public record RankingStagingRankRow(int rank, long productId, BigDecimal score) {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java
new file mode 100644
index 0000000000..1c2c12368e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java
@@ -0,0 +1,18 @@
+package com.loopers.domain.ranking.batch;
+
+import java.util.List;
+
+/**
+ * 랭킹 스테이징 MV 적재 포트.
+ */
+public interface RankingStagingRepository {
+
+ void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey);
+
+ void saveRankedRows(String periodType, String periodKey, List rows);
+
+ /**
+ * publish 직전 조회. rank 오름차순.
+ */
+ List findRankedRows(String periodType, String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java
new file mode 100644
index 0000000000..53d08487a8
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java
@@ -0,0 +1,42 @@
+package com.loopers.domain.ranking.batch;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Option A publish 직전 스테이징 스냅샷 검증.
+ */
+public final class RankingStagingSnapshotValidator {
+
+ private static final int TOP_LIMIT = 100;
+
+ private RankingStagingSnapshotValidator() {
+ }
+
+ /**
+ * 검증에 실패하면 {@link IllegalArgumentException}을 던진다.
+ */
+ public static void validateOrThrow(List rows) {
+ if (rows.size() > TOP_LIMIT) {
+ throw new IllegalArgumentException(
+ "스테이징 행 수는 " + TOP_LIMIT + "을 넘을 수 없습니다. 실제: " + rows.size()
+ );
+ }
+ for (int i = 0; i < rows.size(); i++) {
+ int expectedRank = i + 1;
+ if (rows.get(i).rank() != expectedRank) {
+ throw new IllegalArgumentException(
+ "rank는 1부터 연속이어야 합니다. 인덱스 " + i + "에서 기대 " + expectedRank
+ + ", 실제 " + rows.get(i).rank()
+ );
+ }
+ }
+ Set productIds = new HashSet<>();
+ for (RankingStagingRankRow row : rows) {
+ if (!productIds.add(row.productId())) {
+ throw new IllegalArgumentException("product_id가 중복입니다: " + row.productId());
+ }
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java
new file mode 100644
index 0000000000..50866b2a86
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java
@@ -0,0 +1,62 @@
+package com.loopers.domain.ranking.batch;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.PriorityQueue;
+
+/**
+ * product_metrics 전체를 스캔하면서 상위 100개만 유지한다. 동점은 product_id 오름차순이 앞 순위.
+ */
+public class RankingTop100Accumulator {
+
+ private static final int CAPACITY = 100;
+
+ /**
+ * 힙 순서: 더 나쁜(점수 낮음, 동점이면 id 큼) 후보가 루트.
+ */
+ private static final Comparator WORST_TO_BETTER =
+ Comparator.comparingDouble(RankingScoreCandidate::score)
+ .thenComparing(Comparator.comparingLong(RankingScoreCandidate::productId).reversed());
+
+ private final PriorityQueue minHeap = new PriorityQueue<>(WORST_TO_BETTER);
+
+ /**
+ * 후보를 반영한다. 상위 100을 넘으면 가장 낮은 후보를 제거한다.
+ */
+ public void accept(RankingScoreCandidate candidate) {
+ if (minHeap.size() < CAPACITY) {
+ minHeap.add(candidate);
+ return;
+ }
+ RankingScoreCandidate worst = minHeap.peek();
+ if (worst == null) {
+ return;
+ }
+ if (WORST_TO_BETTER.compare(worst, candidate) < 0) {
+ minHeap.poll();
+ minHeap.add(candidate);
+ }
+ }
+
+ /**
+ * rank 1(최고 점수)부터 오름차순으로 정렬된 행 목록을 만든다.
+ */
+ public List toSortedRankRows() {
+ if (minHeap.isEmpty()) {
+ return List.of();
+ }
+ List sorted = new ArrayList<>(minHeap);
+ sorted.sort(
+ Comparator.comparingDouble(RankingScoreCandidate::score).reversed()
+ .thenComparingLong(RankingScoreCandidate::productId)
+ );
+ List rows = new ArrayList<>(sorted.size());
+ int rank = 1;
+ for (RankingScoreCandidate c : sorted) {
+ rows.add(new RankingStagingRankRow(rank++, c.productId(), BigDecimal.valueOf(c.score())));
+ }
+ return rows;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java
new file mode 100644
index 0000000000..958d8a9b7b
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.domain.ranking.mv;
+
+import java.util.List;
+
+/**
+ * {@code mv_product_rank_monthly} 접근 포트. 구현체는 infrastructure 레이어에 둔다.
+ */
+public interface ProductRankMonthlyRepository {
+
+ void save(ProductRankMvRow row);
+
+ List findByPeriodKeyOrderByRankAsc(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java
new file mode 100644
index 0000000000..4467c5696c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.ranking.mv;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * 주간/월간 MV를 스테이징 검증 이후 원자적으로 교체한다(Option A publish).
+ */
+public interface ProductRankMvPublishRepository {
+
+ void replaceWeeklyPeriod(String periodKey, List rows, Instant publishedAt);
+
+ void replaceMonthlyPeriod(String periodKey, List rows, Instant publishedAt);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java
new file mode 100644
index 0000000000..2914aaa438
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java
@@ -0,0 +1,40 @@
+package com.loopers.domain.ranking.mv;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.Optional;
+
+/**
+ * 주간/월간 랭킹 MV 조회·적재용 불변 행.
+ */
+public record ProductRankMvRow(
+ Optional id,
+ String periodKey,
+ long productId,
+ int rank,
+ BigDecimal score,
+ int version,
+ Instant updatedAt
+) {
+
+ /**
+ * 새로운 행을 생성한다.
+ *
+ * @param periodKey 기간 키
+ * @param productId 상품 ID
+ * @param rank 랭킹
+ * @param score 랭킹 점수
+ * @param version 버전
+ * @param updatedAt 업데이트 시간
+ */
+ public static ProductRankMvRow newRow(
+ String periodKey,
+ long productId,
+ int rank,
+ BigDecimal score,
+ int version,
+ Instant updatedAt
+ ) {
+ return new ProductRankMvRow(Optional.empty(), periodKey, productId, rank, score, version, updatedAt);
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java
new file mode 100644
index 0000000000..40a6a87157
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.domain.ranking.mv;
+
+import java.util.List;
+
+/**
+ * {@code mv_product_rank_weekly} 접근 포트. 구현체는 infrastructure 레이어에 둔다.
+ */
+public interface ProductRankWeeklyRepository {
+
+ void save(ProductRankMvRow row);
+
+ List findByPeriodKeyOrderByRankAsc(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java
new file mode 100644
index 0000000000..ccbcded67d
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java
@@ -0,0 +1,56 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+@Entity
+@Table(
+ name = "mv_product_rank_staging",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_mv_rank_staging_period_product",
+ columnNames = {"period_type", "period_key", "product_id"}
+ )
+)
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class MvProductRankStagingEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_type", nullable = false, length = 16)
+ private String periodType;
+
+ @Column(name = "period_key", nullable = false, length = 16)
+ private String periodKey;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "`rank`", nullable = false)
+ private int rankValue;
+
+ @Column(name = "score", nullable = false, precision = 24, scale = 8)
+ private BigDecimal score;
+
+ @Column(name = "version", nullable = false)
+ private int version;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java
new file mode 100644
index 0000000000..75803f3a6e
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface MvProductRankStagingJpaRepository extends JpaRepository {
+
+ void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey);
+
+ List findByPeriodTypeAndPeriodKeyOrderByRankValueAsc(String periodType, String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java
new file mode 100644
index 0000000000..59f7f20203
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java
@@ -0,0 +1,68 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+/**
+ * 배치가 {@code product_metrics}를 읽기 위한 매핑 엔티티(스트리머와 동일 테이블).
+ */
+@Entity
+@Table(name = "product_metrics")
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class ProductMetricsEntity {
+
+ @Id
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "like_count", nullable = false)
+ private long likeCount;
+
+ @Column(name = "view_count", nullable = false)
+ private long viewCount;
+
+ @Column(name = "sold_quantity", nullable = false)
+ private long soldQuantity;
+
+ @Column(name = "last_event_occurred_at")
+ private Instant lastEventOccurredAt;
+
+ @Column(name = "last_like_event_occurred_at")
+ private Instant lastLikeEventOccurredAt;
+
+ @Column(name = "last_view_event_occurred_at")
+ private Instant lastViewEventOccurredAt;
+
+ @Column(name = "last_sold_event_occurred_at")
+ private Instant lastSoldEventOccurredAt;
+
+ @Column(name = "updated_at")
+ private Instant updatedAt;
+
+ public static ProductMetricsEntity forRankingRead(
+ long productId,
+ long likeCount,
+ long viewCount,
+ long soldQuantity,
+ Instant updatedAt
+ ) {
+ ProductMetricsEntity e = new ProductMetricsEntity();
+ e.setProductId(productId);
+ e.setLikeCount(likeCount);
+ e.setViewCount(viewCount);
+ e.setSoldQuantity(soldQuantity);
+ e.setUpdatedAt(updatedAt);
+ return e;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java
new file mode 100644
index 0000000000..15a50c9ffb
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java
@@ -0,0 +1,6 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ProductMetricsJpaRepository extends JpaRepository {
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java
new file mode 100644
index 0000000000..63ae67865c
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java
@@ -0,0 +1,73 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import com.loopers.domain.ranking.batch.RankingStagingRankRow;
+import com.loopers.domain.ranking.batch.RankingStagingRepository;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@Repository
+public class RankingStagingRepositoryImpl implements RankingStagingRepository {
+
+ private final MvProductRankStagingJpaRepository jpaRepository;
+
+ public RankingStagingRepositoryImpl(MvProductRankStagingJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ /**
+ * 스테이징 MV를 삭제한다.
+ *
+ * @param periodType 기간 타입
+ * @param periodKey 기간 키
+ */
+ @Override
+ @Transactional
+ public void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey) {
+ jpaRepository.deleteByPeriodTypeAndPeriodKey(periodType, periodKey);
+ }
+
+ /**
+ * 스테이징 MV를 저장한다.
+ *
+ * @param periodType 기간 타입
+ * @param periodKey 기간 키
+ * @param rows 랭킹 스테이징 행
+ */
+ @Override
+ @Transactional
+ public void saveRankedRows(String periodType, String periodKey, List rows) {
+ Instant now = Instant.now();
+ List entities = new ArrayList<>(rows.size());
+ for (RankingStagingRankRow row : rows) {
+ MvProductRankStagingEntity e = new MvProductRankStagingEntity();
+ e.setPeriodType(periodType);
+ e.setPeriodKey(periodKey);
+ e.setProductId(row.productId());
+ e.setRankValue(row.rank());
+ e.setScore(row.score());
+ e.setVersion(0);
+ e.setUpdatedAt(now);
+ entities.add(e);
+ }
+ jpaRepository.saveAll(entities);
+ }
+
+ /**
+ * 스테이징 MV를 조회한다.
+ *
+ * @param periodType 기간 타입
+ * @param periodKey 기간 키
+ * @return 랭킹 스테이징 행
+ */
+ @Override
+ @Transactional(readOnly = true)
+ public List findRankedRows(String periodType, String periodKey) {
+ return jpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc(periodType, periodKey).stream()
+ .map(e -> new RankingStagingRankRow(e.getRankValue(), e.getProductId(), e.getScore()))
+ .toList();
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java
new file mode 100644
index 0000000000..8e0d54ea01
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java
@@ -0,0 +1,59 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Redis SET NX 기반 period 락. Job 설정 클래스에서만 Bean으로 등록한다.
+ */
+public final class RedisRankingBatchLock {
+
+ private static final Duration LOCK_TTL = Duration.ofHours(4);
+
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * 생성자 주입.
+ *
+ * @param redisTemplate RedisTemplate
+ */
+ public RedisRankingBatchLock(RedisTemplate redisTemplate) {
+ this.redisTemplate = redisTemplate;
+ }
+
+ /**
+ * 락을 획득한다.
+ *
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @param ownerToken 소유자 토큰
+ * @return 락 획득 여부
+ */
+ public boolean tryAcquire(String period, String periodKey, String ownerToken) {
+ Objects.requireNonNull(ownerToken, "ownerToken");
+ String key = RankingBatchJobParameters.redisLockKey(period, periodKey);
+ Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, ownerToken, LOCK_TTL);
+ return Boolean.TRUE.equals(ok);
+ }
+
+ /**
+ * 락을 해제한다.
+ *
+ * @param period 기간
+ * @param periodKey 기간 키
+ * @param ownerToken 소유자 토큰
+ */
+ public void releaseIfHeld(String period, String periodKey, String ownerToken) {
+ if (ownerToken == null) {
+ return;
+ }
+ String key = RankingBatchJobParameters.redisLockKey(period, periodKey);
+ String current = redisTemplate.opsForValue().get(key);
+ if (ownerToken.equals(current)) {
+ redisTemplate.delete(key);
+ }
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java
new file mode 100644
index 0000000000..fcf7eaf486
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java
@@ -0,0 +1,61 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+/**
+ * 월간 랭킹 MV 엔티티.
+ * UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.
+ * period_key: yyyyMMdd, product_id: product.id, rank: int, score: decimal(24,8), version: int, updated_at: timestamp.
+ *
+ * 낙관적 락: @Version 필드로 동시 사용 시 커밋 단계에서 충돌 감지·롤백.
+ * JPA는 UPDATE 시 {@code WHERE id = ? AND version = ?}를 사용하며, 버전 불일치 시 수정 행 0 → OptimisticLockException. (05-transaction-query §3.1)
+ */
+@Entity
+@Table(
+ name = "mv_product_rank_monthly",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_mv_product_rank_monthly_period_product",
+ columnNames = {"period_key", "product_id"}
+ )
+)
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class MvProductRankMonthlyEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_key", nullable = false, length = 16)
+ private String periodKey;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "`rank`", nullable = false)
+ private int rankValue;
+
+ @Column(name = "score", nullable = false, precision = 24, scale = 8)
+ private BigDecimal score;
+
+ @Column(name = "version", nullable = false)
+ private int version;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java
new file mode 100644
index 0000000000..fb08f4b603
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface MvProductRankMonthlyJpaRepository extends JpaRepository {
+
+ List findByPeriodKeyOrderByRankValueAsc(String periodKey);
+
+ void deleteByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java
new file mode 100644
index 0000000000..287cd8e556
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java
@@ -0,0 +1,61 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+
+import static lombok.AccessLevel.PROTECTED;
+
+/**
+ * 주간 랭킹 MV 엔티티.
+ * UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.
+ * period_key: yyyyMMdd, product_id: product.id, rank: int, score: decimal(24,8), version: int, updated_at: timestamp.
+ *
+ * @Version 필드로 동시 사용 시 커밋 단계에서 충돌 감지·롤백.
+ * JPA는 UPDATE 시 {@code WHERE id = ? AND version = ?}를 사용하며, 버전 불일치 시 수정 행 0 → OptimisticLockException. (05-transaction-query §3.1)
+ */
+@Entity
+@Table(
+ name = "mv_product_rank_weekly",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uk_mv_product_rank_weekly_period_product",
+ columnNames = {"period_key", "product_id"}
+ )
+)
+@Getter
+@Setter
+@NoArgsConstructor(access = PROTECTED)
+public class MvProductRankWeeklyEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "period_key", nullable = false, length = 16)
+ private String periodKey;
+
+ @Column(name = "product_id", nullable = false)
+ private Long productId;
+
+ @Column(name = "`rank`", nullable = false)
+ private int rankValue;
+
+ @Column(name = "score", nullable = false, precision = 24, scale = 8)
+ private BigDecimal score;
+
+ @Column(name = "version", nullable = false)
+ private int version;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java
new file mode 100644
index 0000000000..f93edbd838
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface MvProductRankWeeklyJpaRepository extends JpaRepository {
+
+ List findByPeriodKeyOrderByRankValueAsc(String periodKey);
+
+ void deleteByPeriodKey(String periodKey);
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java
new file mode 100644
index 0000000000..4c1d0bce22
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java
@@ -0,0 +1,53 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.mv.ProductRankMonthlyRepository;
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class ProductRankMonthlyRepositoryImpl implements ProductRankMonthlyRepository {
+
+ private final MvProductRankMonthlyJpaRepository jpaRepository;
+
+ public ProductRankMonthlyRepositoryImpl(MvProductRankMonthlyJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ @Override
+ public void save(ProductRankMvRow row) {
+ jpaRepository.save(toEntity(row));
+ }
+
+ @Override
+ public List findByPeriodKeyOrderByRankAsc(String periodKey) {
+ return jpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream()
+ .map(ProductRankMonthlyRepositoryImpl::toRow)
+ .toList();
+ }
+
+ private static MvProductRankMonthlyEntity toEntity(ProductRankMvRow row) {
+ MvProductRankMonthlyEntity entity = new MvProductRankMonthlyEntity();
+ entity.setPeriodKey(row.periodKey());
+ entity.setProductId(row.productId());
+ entity.setRankValue(row.rank());
+ entity.setScore(row.score());
+ entity.setVersion(row.version());
+ entity.setUpdatedAt(row.updatedAt());
+ return entity;
+ }
+
+ private static ProductRankMvRow toRow(MvProductRankMonthlyEntity entity) {
+ return new ProductRankMvRow(
+ Optional.ofNullable(entity.getId()),
+ entity.getPeriodKey(),
+ entity.getProductId(),
+ entity.getRankValue(),
+ entity.getScore(),
+ entity.getVersion(),
+ entity.getUpdatedAt()
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java
new file mode 100644
index 0000000000..04157e57db
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java
@@ -0,0 +1,97 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository;
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.util.List;
+
+@Repository
+public class ProductRankMvPublishRepositoryImpl implements ProductRankMvPublishRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
+ private final MvProductRankMonthlyJpaRepository monthlyJpaRepository;
+
+ public ProductRankMvPublishRepositoryImpl(
+ MvProductRankWeeklyJpaRepository weeklyJpaRepository,
+ MvProductRankMonthlyJpaRepository monthlyJpaRepository
+ ) {
+ this.weeklyJpaRepository = weeklyJpaRepository;
+ this.monthlyJpaRepository = monthlyJpaRepository;
+ }
+
+ /**
+ * 주간 랭킹 MV를 대체한다.
+ *
+ * @param periodKey 기간 키
+ * @param rows 랭킹 행
+ * @param publishedAt 발행 시간
+ */
+ @Override
+ @Transactional
+ public void replaceWeeklyPeriod(String periodKey, List rows, Instant publishedAt) {
+ weeklyJpaRepository.deleteByPeriodKey(periodKey);
+ entityManager.flush();
+ entityManager.clear();
+ weeklyJpaRepository.saveAll(rows.stream().map(r -> toWeeklyEntity(r, publishedAt)).toList());
+ }
+
+ /**
+ * 월간 랭킹 MV를 대체한다.
+ *
+ * @param periodKey 기간 키
+ * @param rows 랭킹 행
+ * @param publishedAt 발행 시간
+ */
+ @Override
+ @Transactional
+ public void replaceMonthlyPeriod(String periodKey, List rows, Instant publishedAt) {
+ monthlyJpaRepository.deleteByPeriodKey(periodKey);
+ entityManager.flush();
+ entityManager.clear();
+ monthlyJpaRepository.saveAll(rows.stream().map(r -> toMonthlyEntity(r, publishedAt)).toList());
+ }
+
+ /**
+ * 주간 랭킹 MV 엔티티를 생성한다.
+ *
+ * @param row 랭킹 행
+ * @param publishedAt 발행 시간
+ * @return 주간 랭킹 MV 엔티티
+ */
+ private static MvProductRankWeeklyEntity toWeeklyEntity(ProductRankMvRow row, Instant publishedAt) {
+ MvProductRankWeeklyEntity e = new MvProductRankWeeklyEntity();
+ e.setPeriodKey(row.periodKey());
+ e.setProductId(row.productId());
+ e.setRankValue(row.rank());
+ e.setScore(row.score());
+ e.setVersion(row.version());
+ e.setUpdatedAt(publishedAt);
+ return e;
+ }
+
+ /**
+ * 월간 랭킹 MV 엔티티를 생성한다.
+ *
+ * @param row 랭킹 행
+ * @param publishedAt 발행 시간
+ * @return 월간 랭킹 MV 엔티티
+ */
+ private static MvProductRankMonthlyEntity toMonthlyEntity(ProductRankMvRow row, Instant publishedAt) {
+ MvProductRankMonthlyEntity e = new MvProductRankMonthlyEntity();
+ e.setPeriodKey(row.periodKey());
+ e.setProductId(row.productId());
+ e.setRankValue(row.rank());
+ e.setScore(row.score());
+ e.setVersion(row.version());
+ e.setUpdatedAt(publishedAt);
+ return e;
+ }
+}
diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java
new file mode 100644
index 0000000000..0d2551286a
--- /dev/null
+++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java
@@ -0,0 +1,53 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+import com.loopers.domain.ranking.mv.ProductRankWeeklyRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public class ProductRankWeeklyRepositoryImpl implements ProductRankWeeklyRepository {
+
+ private final MvProductRankWeeklyJpaRepository jpaRepository;
+
+ public ProductRankWeeklyRepositoryImpl(MvProductRankWeeklyJpaRepository jpaRepository) {
+ this.jpaRepository = jpaRepository;
+ }
+
+ @Override
+ public void save(ProductRankMvRow row) {
+ jpaRepository.save(toEntity(row));
+ }
+
+ @Override
+ public List findByPeriodKeyOrderByRankAsc(String periodKey) {
+ return jpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream()
+ .map(ProductRankWeeklyRepositoryImpl::toRow)
+ .toList();
+ }
+
+ private static MvProductRankWeeklyEntity toEntity(ProductRankMvRow row) {
+ MvProductRankWeeklyEntity entity = new MvProductRankWeeklyEntity();
+ entity.setPeriodKey(row.periodKey());
+ entity.setProductId(row.productId());
+ entity.setRankValue(row.rank());
+ entity.setScore(row.score());
+ entity.setVersion(row.version());
+ entity.setUpdatedAt(row.updatedAt());
+ return entity;
+ }
+
+ private static ProductRankMvRow toRow(MvProductRankWeeklyEntity entity) {
+ return new ProductRankMvRow(
+ Optional.ofNullable(entity.getId()),
+ entity.getPeriodKey(),
+ entity.getProductId(),
+ entity.getRankValue(),
+ entity.getScore(),
+ entity.getVersion(),
+ entity.getUpdatedAt()
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml
index ba5fa9fd03..8957de989f 100644
--- a/apps/commerce-batch/src/main/resources/application.yml
+++ b/apps/commerce-batch/src/main/resources/application.yml
@@ -3,6 +3,9 @@ spring:
web-application-type: none
application:
name: commerce-batch
+ # 운영에서 MV 스키마는 classpath:db/migration (Flyway). local/test는 Hibernate create + Flyway 비활성.
+ flyway:
+ enabled: false
profiles:
active: local
config:
@@ -64,6 +67,17 @@ outbox:
cleanup:
enabled: false
+---
+spring:
+ config:
+ activate:
+ on-profile: dev, qa, prd
+ flyway:
+ enabled: true
+ locations: classpath:db/migration
+ baseline-on-migrate: true
+ table: flyway_schema_history_commerce_batch
+
---
spring:
config:
diff --git a/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql b/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql
new file mode 100644
index 0000000000..13de88dc3f
--- /dev/null
+++ b/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql
@@ -0,0 +1,26 @@
+-- Round 10: 주간/월간 랭킹 MV (운영 스키마는 Flyway로만 변경; JPA 엔티티와 동일 스키마 유지)
+CREATE TABLE IF NOT EXISTS mv_product_rank_weekly (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ period_key VARCHAR(16) NOT NULL,
+ product_id BIGINT NOT NULL,
+ `rank` INT NOT NULL,
+ score DECIMAL(24, 8) NOT NULL,
+ version INT NOT NULL,
+ updated_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mv_product_rank_weekly_period_product (period_key, product_id),
+ KEY idx_mv_product_rank_weekly_period_rank (period_key, `rank`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS mv_product_rank_monthly (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ period_key VARCHAR(16) NOT NULL,
+ product_id BIGINT NOT NULL,
+ `rank` INT NOT NULL,
+ score DECIMAL(24, 8) NOT NULL,
+ version INT NOT NULL,
+ updated_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mv_product_rank_monthly_period_product (period_key, product_id),
+ KEY idx_mv_product_rank_monthly_period_rank (period_key, `rank`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql b/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql
new file mode 100644
index 0000000000..657325adde
--- /dev/null
+++ b/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql
@@ -0,0 +1,14 @@
+-- Round 10: 집계 결과를 MV 반영 전에 담는 스테이징 테이블
+CREATE TABLE IF NOT EXISTS mv_product_rank_staging (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ period_type VARCHAR(16) NOT NULL,
+ period_key VARCHAR(16) NOT NULL,
+ product_id BIGINT NOT NULL,
+ `rank` INT NOT NULL,
+ score DECIMAL(24, 8) NOT NULL,
+ version INT NOT NULL DEFAULT 0,
+ updated_at DATETIME(6) NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_mv_rank_staging_period_product (period_type, period_key, product_id),
+ KEY idx_mv_rank_staging_period_rank (period_type, period_key, `rank`)
+);
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java
new file mode 100644
index 0000000000..b3c5c9efdd
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java
@@ -0,0 +1,68 @@
+package com.loopers.batch.ranking;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RankingPeriodKeyTest {
+
+ @Test
+ @DisplayName("weekly: 같은 ISO 주에 속한 날짜들은 동일한 yyyyMMdd(월요일 앵커) 키를 가진다.")
+ void weekly_whenDatesInSameIsoWeek_shouldShareMondayAnchor() {
+ // given
+ LocalDate wednesday = LocalDate.of(2026, 4, 8); // 수요일
+ LocalDate friday = LocalDate.of(2026, 4, 10); // 같은 주 금요일
+
+ // when
+ String key1 = RankingPeriodKey.weekly(wednesday);
+ String key2 = RankingPeriodKey.weekly(friday);
+
+ // then
+ assertThat(key1).isEqualTo("20260406"); // 해당 주 월요일
+ assertThat(key2).isEqualTo("20260406");
+ }
+
+ @Test
+ @DisplayName("weekly: 2026-01-01(목) 연초는 ISO 주의 월요일 앵커(전년 12/29)로 귀속된다.")
+ void weekly_whenNewYearsDay2026_shouldMapToMondayAnchorInPriorDecember() {
+ LocalDate newYears = LocalDate.of(2026, 1, 1);
+ LocalDate mondaySameIsoWeek = LocalDate.of(2025, 12, 29);
+
+ assertThat(RankingPeriodKey.weekly(newYears)).isEqualTo("20251229");
+ assertThat(RankingPeriodKey.weekly(mondaySameIsoWeek)).isEqualTo("20251229");
+ }
+
+ @Test
+ @DisplayName("weekly: 월요일 시작 규칙 — 앵커는 항상 해당 주의 월요일 yyyyMMdd이다.")
+ void weekly_whenSundayInWeek_shouldStillResolveToMondayOfThatWeek() {
+ LocalDate sunday = LocalDate.of(2026, 4, 12);
+ assertThat(RankingPeriodKey.weekly(sunday)).isEqualTo("20260406");
+ }
+
+ @Test
+ @DisplayName("monthly: yyyyMMdd 형식으로 해당 월의 첫째 날 키를 만든다.")
+ void monthly_whenLocalDateGiven_shouldReturnFirstDayYyyyMmDd() {
+ // given
+ LocalDate date = LocalDate.of(2026, 4, 16);
+
+ // when
+ String key = RankingPeriodKey.monthly(date);
+
+ // then
+ assertThat(key).isEqualTo("20260401");
+ }
+
+ @Test
+ @DisplayName("null을 넘기면 IllegalArgumentException을 던진다.")
+ void weeklyAndMonthly_whenNull_shouldThrow() {
+ assertThatThrownBy(() -> RankingPeriodKey.weekly(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> RankingPeriodKey.monthly(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
+
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java
new file mode 100644
index 0000000000..92e426272f
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java
@@ -0,0 +1,94 @@
+package com.loopers.batch.ranking.metrics;
+
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.batch.core.BatchStatus;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobInstance;
+import org.springframework.batch.core.JobParameters;
+import org.springframework.batch.core.JobParametersBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RankingBatchJobMetricsListenerTest {
+
+ @Mock
+ private RankingBatchJobMetrics rankingBatchJobMetrics;
+
+ private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
+
+ private RankingBatchJobMetricsListener listener;
+
+ @BeforeEach
+ void setUp() {
+ listener = new RankingBatchJobMetricsListener(meterRegistry, rankingBatchJobMetrics);
+ }
+
+ @Test
+ @DisplayName("COMPLETED이면 recordSuccess 호출")
+ void afterJob_whenCompleted_shouldRecordSuccess() {
+ JobExecution execution = mockJobExecution(RankingBatchJobParameters.JOB_NAME, BatchStatus.COMPLETED);
+
+ listener.afterJob(execution);
+
+ verify(rankingBatchJobMetrics).recordSuccess("WEEKLY", "2026W15");
+ assertThat(meterRegistry.find("batch.rank.job.failure.count").meters()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("FAILED이면 failure 카운터 증가")
+ void afterJob_whenFailed_shouldIncrementFailureCounter() {
+ JobExecution execution = mockJobExecution(RankingBatchJobParameters.JOB_NAME, BatchStatus.FAILED);
+
+ listener.afterJob(execution);
+
+ verify(rankingBatchJobMetrics, never()).recordSuccess(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
+ assertThat(
+ meterRegistry.counter(
+ "batch.rank.job.failure.count",
+ "period", "WEEKLY",
+ "period_key", "2026W15",
+ "batch_status", "FAILED"
+ ).count()
+ ).isEqualTo(1.0d);
+ }
+
+ @Test
+ @DisplayName("다른 Job이면 메트릭 없음")
+ void afterJob_whenOtherJob_shouldNoop() {
+ JobInstance inst = mock(JobInstance.class);
+ when(inst.getJobName()).thenReturn("otherJob");
+ JobExecution execution = mock(JobExecution.class);
+ when(execution.getJobInstance()).thenReturn(inst);
+
+ listener.afterJob(execution);
+
+ verify(rankingBatchJobMetrics, never()).recordSuccess(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString());
+ assertThat(meterRegistry.find("batch.rank.job.failure.count").meters()).isEmpty();
+ }
+
+ private static JobExecution mockJobExecution(String jobName, BatchStatus status) {
+ JobInstance inst = mock(JobInstance.class);
+ when(inst.getJobName()).thenReturn(jobName);
+ JobParameters params = new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY")
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15")
+ .toJobParameters();
+ JobExecution execution = mock(JobExecution.class);
+ when(execution.getJobInstance()).thenReturn(inst);
+ when(execution.getJobParameters()).thenReturn(params);
+ when(execution.getStatus()).thenReturn(status);
+ return execution;
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java
new file mode 100644
index 0000000000..ef8c6b61ad
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java
@@ -0,0 +1,23 @@
+package com.loopers.batch.ranking.metrics;
+
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RankingBatchJobMetricsTest {
+
+ @Test
+ @DisplayName("recordSuccess 시 last.success·stale 게이지 등록")
+ void recordSuccess_shouldRegisterGauges() {
+ SimpleMeterRegistry registry = new SimpleMeterRegistry();
+ RankingBatchJobMetrics metrics = new RankingBatchJobMetrics(registry);
+
+ metrics.recordSuccess("WEEKLY", "2026W15");
+
+ assertThat(registry.find("batch.rank.job.last.success.epoch").gauges()).isNotEmpty();
+ assertThat(registry.find("batch.rank.snapshot.stale.seconds").gauges()).isNotEmpty();
+ assertThat(registry.find("batch.rank.job.last.success.epoch").gauge().value()).isGreaterThan(0);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java
new file mode 100644
index 0000000000..60d447bd4c
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java
@@ -0,0 +1,54 @@
+package com.loopers.domain.ranking.batch;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RankingBatchJobParametersTest {
+
+ @Test
+ @DisplayName("WEEKLY + yyyyWww 형식이면 통과한다.")
+ void validate_whenWeeklyOk_shouldPass() {
+ assertThatCode(() -> RankingBatchJobParameters.validate("WEEKLY", "2026W15"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("WEEKLY인데 주차 범위 밖이면 실패한다.")
+ void validate_whenWeeklyWeekOutOfRange_shouldThrow() {
+ assertThatThrownBy(() -> RankingBatchJobParameters.validate("WEEKLY", "2026W54"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("주차");
+ }
+
+ @Test
+ @DisplayName("MONTHLY + yyyyMM 형식이면 통과한다.")
+ void validate_whenMonthlyOk_shouldPass() {
+ assertThatCode(() -> RankingBatchJobParameters.validate("MONTHLY", "202604"))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("MONTHLY인데 월이 무효이면 실패한다.")
+ void validate_whenMonthlyInvalidMonth_shouldThrow() {
+ assertThatThrownBy(() -> RankingBatchJobParameters.validate("MONTHLY", "202613"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("월");
+ }
+
+ @Test
+ @DisplayName("period가 잘못되면 실패한다.")
+ void validate_whenPeriodInvalid_shouldThrow() {
+ assertThatThrownBy(() -> RankingBatchJobParameters.validate("DAILY", "202604"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("periodKey가 비어 있으면 실패한다.")
+ void validate_whenPeriodKeyBlank_shouldThrow() {
+ assertThatThrownBy(() -> RankingBatchJobParameters.validate("WEEKLY", " "))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java
new file mode 100644
index 0000000000..005e6887fd
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java
@@ -0,0 +1,24 @@
+package com.loopers.domain.ranking.batch;
+
+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.assertThatThrownBy;
+
+class RankingMvScoreCalculatorTest {
+
+ @Test
+ @DisplayName("조회·좋아요·판매에 기본 가중치(0.1,0.2,0.6)를 곱해 합산한다.")
+ void score_withUnitCounts_matchesWeightedSum() {
+ double s = RankingMvScoreCalculator.score(1L, 1L, 1L);
+ assertThat(s).isEqualTo(0.1d + 0.2d + 0.6d);
+ }
+
+ @Test
+ @DisplayName("음수 카운트면 IllegalArgumentException이다.")
+ void score_withNegativeCount_shouldFail() {
+ assertThatThrownBy(() -> RankingMvScoreCalculator.score(-1L, 0L, 0L))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java
new file mode 100644
index 0000000000..0c3712a678
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java
@@ -0,0 +1,68 @@
+package com.loopers.domain.ranking.batch;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RankingStagingSnapshotValidatorTest {
+
+ @Test
+ @DisplayName("빈 스냅샷은 통과한다.")
+ void validate_whenEmpty_doesNotThrow() {
+ assertThatCode(() -> RankingStagingSnapshotValidator.validateOrThrow(List.of()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("rank가 1부터 연속이면 통과한다.")
+ void validate_whenRanksContiguous_doesNotThrow() {
+ var rows = List.of(
+ new RankingStagingRankRow(1, 10L, BigDecimal.ONE),
+ new RankingStagingRankRow(2, 20L, BigDecimal.TEN)
+ );
+ assertThatCode(() -> RankingStagingSnapshotValidator.validateOrThrow(rows))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ @DisplayName("행이 100개를 넘으면 실패한다.")
+ void validate_whenMoreThan100_shouldFail() {
+ var rows = new ArrayList();
+ for (int i = 1; i <= 101; i++) {
+ rows.add(new RankingStagingRankRow(i, (long) i, BigDecimal.valueOf(i)));
+ }
+ assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("100");
+ }
+
+ @Test
+ @DisplayName("rank에 공백이 있으면 실패한다.")
+ void validate_whenRankGap_shouldFail() {
+ var rows = List.of(
+ new RankingStagingRankRow(1, 1L, BigDecimal.ONE),
+ new RankingStagingRankRow(3, 2L, BigDecimal.TEN)
+ );
+ assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("연속");
+ }
+
+ @Test
+ @DisplayName("product_id 중복이면 실패한다.")
+ void validate_whenDuplicateProduct_shouldFail() {
+ var rows = List.of(
+ new RankingStagingRankRow(1, 1L, BigDecimal.ONE),
+ new RankingStagingRankRow(2, 1L, BigDecimal.TEN)
+ );
+ assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("중복");
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java
new file mode 100644
index 0000000000..d2b19a9eb4
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java
@@ -0,0 +1,69 @@
+package com.loopers.domain.ranking.batch;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.stream.LongStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RankingTop100AccumulatorTest {
+
+ @Test
+ @DisplayName("후보가 100개 이하면 모두 rank에 포함된다.")
+ void toSortedRankRows_whenAtMost100_returnsAllOrdered() {
+ RankingTop100Accumulator acc = new RankingTop100Accumulator();
+ acc.accept(new RankingScoreCandidate(2L, 10.0d));
+ acc.accept(new RankingScoreCandidate(1L, 20.0d));
+
+ var rows = acc.toSortedRankRows();
+
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).rank()).isEqualTo(1);
+ assertThat(rows.get(0).productId()).isEqualTo(1L);
+ assertThat(rows.get(1).productId()).isEqualTo(2L);
+ }
+
+ @Test
+ @DisplayName("후보가 100개를 넘으면 점수 하위는 제외된다.")
+ void toSortedRankRows_whenMoreThan100_keepsTop100ByScore() {
+ RankingTop100Accumulator acc = new RankingTop100Accumulator();
+ for (long i = 1L; i <= 100L; i++) {
+ acc.accept(new RankingScoreCandidate(i, (double) i));
+ }
+ acc.accept(new RankingScoreCandidate(999L, 0.5d));
+
+ var rows = acc.toSortedRankRows();
+
+ assertThat(rows).hasSize(100);
+ assertThat(rows.get(0).productId()).isEqualTo(100L);
+ assertThat(rows.get(99).productId()).isEqualTo(1L);
+ assertThat(rows.stream().anyMatch(r -> r.productId() == 999L)).isFalse();
+ }
+
+ @Test
+ @DisplayName("동점이면 product_id가 작은 쪽이 더 높은 순위다.")
+ void toSortedRankRows_whenScoreTie_ordersByProductIdAsc() {
+ RankingTop100Accumulator acc = new RankingTop100Accumulator();
+ acc.accept(new RankingScoreCandidate(20L, 5.0d));
+ acc.accept(new RankingScoreCandidate(10L, 5.0d));
+
+ var rows = acc.toSortedRankRows();
+
+ assertThat(rows.get(0).productId()).isEqualTo(10L);
+ assertThat(rows.get(1).productId()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("동점 상위 100 경계에서 id가 작은 상품이 남는다.")
+ void accept_whenTieAtBoundary_prefersLowerProductId() {
+ RankingTop100Accumulator acc = new RankingTop100Accumulator();
+ LongStream.rangeClosed(1L, 100L).forEach(i -> acc.accept(new RankingScoreCandidate(i, 1.0d)));
+ acc.accept(new RankingScoreCandidate(200L, 1.0d));
+
+ var rows = acc.toSortedRankRows();
+
+ assertThat(rows).hasSize(100);
+ assertThat(rows.stream().anyMatch(r -> r.productId() == 200L)).isFalse();
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java
new file mode 100644
index 0000000000..95864d0836
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java
@@ -0,0 +1,82 @@
+package com.loopers.infrastructure.ranking.batch;
+
+import com.loopers.domain.ranking.batch.RankingStagingRankRow;
+import com.loopers.domain.ranking.batch.RankingStagingRepository;
+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 java.math.BigDecimal;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@Import(MySqlTestContainersConfig.class)
+class RankingStagingRepositoryIntegrationTest {
+
+ @Autowired
+ private RankingStagingRepository rankingStagingRepository;
+
+ @Autowired
+ private MvProductRankStagingJpaRepository stagingJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Test
+ @DisplayName("삭제 후 순위 행을 저장하면 조회 시 rank 오름차순이다.")
+ void saveRankedRows_afterDelete_returnsOrdered() {
+ rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", "2026W15");
+ rankingStagingRepository.saveRankedRows(
+ "WEEKLY",
+ "2026W15",
+ List.of(
+ new RankingStagingRankRow(1, 10L, new BigDecimal("9.00")),
+ new RankingStagingRankRow(2, 20L, new BigDecimal("1.00"))
+ )
+ );
+
+ List rows =
+ stagingJpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc("WEEKLY", "2026W15");
+
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).getRankValue()).isEqualTo(1);
+ assertThat(rows.get(0).getProductId()).isEqualTo(10L);
+ assertThat(rows.get(1).getRankValue()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("findRankedRows는 rank 오름차순 DTO 목록을 반환한다.")
+ void findRankedRows_returnsOrderedDtos() {
+ rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", "2026W15");
+ rankingStagingRepository.saveRankedRows(
+ "WEEKLY",
+ "2026W15",
+ List.of(
+ new RankingStagingRankRow(1, 10L, new BigDecimal("9.00")),
+ new RankingStagingRankRow(2, 20L, new BigDecimal("1.00"))
+ )
+ );
+
+ List dtos = rankingStagingRepository.findRankedRows("WEEKLY", "2026W15");
+
+ assertThat(dtos).hasSize(2);
+ assertThat(dtos.get(0).rank()).isEqualTo(1);
+ assertThat(dtos.get(0).productId()).isEqualTo(10L);
+ assertThat(dtos.get(1).rank()).isEqualTo(2);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java
new file mode 100644
index 0000000000..03986f0edc
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java
@@ -0,0 +1,88 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository;
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+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 java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@Import(MySqlTestContainersConfig.class)
+class ProductRankMvPublishRepositoryIntegrationTest {
+
+ @Autowired
+ private ProductRankMvPublishRepository productRankMvPublishRepository;
+
+ @Autowired
+ private MvProductRankWeeklyJpaRepository weeklyJpaRepository;
+
+ @Autowired
+ private MvProductRankMonthlyJpaRepository monthlyJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Test
+ @DisplayName("주간 MV: 동일 period를 교체하면 이전 행이 사라지고 새 행만 남는다.")
+ void replaceWeeklyPeriod_secondPublish_replacesRows() {
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ String periodKey = "2026W15";
+ productRankMvPublishRepository.replaceWeeklyPeriod(
+ periodKey,
+ List.of(ProductRankMvRow.newRow(periodKey, 1L, 1, new BigDecimal("5"), 1, at)),
+ at
+ );
+ assertThat(weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey)).hasSize(1);
+
+ productRankMvPublishRepository.replaceWeeklyPeriod(
+ periodKey,
+ List.of(
+ ProductRankMvRow.newRow(periodKey, 10L, 1, new BigDecimal("9"), 1, at),
+ ProductRankMvRow.newRow(periodKey, 20L, 2, new BigDecimal("1"), 1, at)
+ ),
+ at
+ );
+ var rows = weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey);
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).getProductId()).isEqualTo(10L);
+ assertThat(rows.get(1).getProductId()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("월간 MV: 교체 후 rank 순으로 조회된다.")
+ void replaceMonthlyPeriod_ordersByRank() {
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ String periodKey = "202604";
+ productRankMvPublishRepository.replaceMonthlyPeriod(
+ periodKey,
+ List.of(
+ ProductRankMvRow.newRow(periodKey, 2L, 2, BigDecimal.ONE, 1, at),
+ ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.TEN, 1, at)
+ ),
+ at
+ );
+ var rows = monthlyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey);
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).getProductId()).isEqualTo(1L);
+ assertThat(rows.get(1).getProductId()).isEqualTo(2L);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java
new file mode 100644
index 0000000000..0ba509f345
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java
@@ -0,0 +1,110 @@
+package com.loopers.infrastructure.ranking.mv;
+
+import com.loopers.domain.ranking.mv.ProductRankMonthlyRepository;
+import com.loopers.domain.ranking.mv.ProductRankMvRow;
+import com.loopers.domain.ranking.mv.ProductRankWeeklyRepository;
+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.dao.DataIntegrityViolationException;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@Import(MySqlTestContainersConfig.class)
+class ProductRankMvRepositoryIntegrationTest {
+
+ @Autowired
+ private ProductRankWeeklyRepository weeklyRepository;
+
+ @Autowired
+ private ProductRankMonthlyRepository monthlyRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @Test
+ @DisplayName("주간 MV: 동일 period에서 rank 오름차순으로 조회한다.")
+ @Transactional
+ void weekly_findByPeriodKeyOrderByRankAsc_samePeriod_returnsOrdered() {
+ // given
+ String periodKey = "2026W15";
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 200L, 2, new BigDecimal("12.5"), 1, at));
+ weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 100L, 1, new BigDecimal("99.0"), 1, at));
+
+ // when
+ List rows = weeklyRepository.findByPeriodKeyOrderByRankAsc(periodKey);
+
+ // then
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).productId()).isEqualTo(100L);
+ assertThat(rows.get(0).rank()).isEqualTo(1);
+ assertThat(rows.get(1).productId()).isEqualTo(200L);
+ assertThat(rows.get(1).rank()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("월간 MV: 동일 period에서 rank 오름차순으로 조회한다.")
+ @Transactional
+ void monthly_findByPeriodKeyOrderByRankAsc_samePeriod_returnsOrdered() {
+ // given
+ String periodKey = "202604";
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 20L, 2, new BigDecimal("3.25"), 1, at));
+ monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 10L, 1, new BigDecimal("8.00"), 1, at));
+
+ // when
+ List rows = monthlyRepository.findByPeriodKeyOrderByRankAsc(periodKey);
+
+ // then
+ assertThat(rows).hasSize(2);
+ assertThat(rows.get(0).productId()).isEqualTo(10L);
+ assertThat(rows.get(1).productId()).isEqualTo(20L);
+ }
+
+ @Test
+ @DisplayName("주간 MV: UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.")
+ void weekly_save_duplicatePeriodAndProduct_shouldFail() {
+ // given
+ String periodKey = "2026W16";
+ Instant at = Instant.parse("2026-05-01T00:00:00Z");
+ weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.ONE, 1, at));
+
+ // when // then
+ assertThatThrownBy(() -> weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 2, BigDecimal.TEN, 1, at)))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ @Test
+ @DisplayName("월간 MV: UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.")
+ void monthly_save_duplicatePeriodAndProduct_shouldFail() {
+ // given
+ String periodKey = "202605";
+ Instant at = Instant.parse("2026-05-01T00:00:00Z");
+ monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.ONE, 1, at));
+
+ // when // then
+ assertThatThrownBy(() -> monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 2, BigDecimal.TEN, 1, at)))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java
new file mode 100644
index 0000000000..9dfad22f2f
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java
@@ -0,0 +1,192 @@
+package com.loopers.job.ranking;
+
+import com.loopers.batch.ranking.job.RankingBatchJobConfig;
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import com.loopers.infrastructure.ranking.batch.MvProductRankStagingJpaRepository;
+import com.loopers.infrastructure.ranking.mv.MvProductRankWeeklyJpaRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+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.Test;
+import org.springframework.batch.core.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.JobParametersInvalidException;
+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.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME)
+@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
+class RankingBatchJobE2ETest {
+
+ private static final List TRUNCATE_TABLES = List.of(
+ "mv_product_rank_staging",
+ "mv_product_rank_weekly",
+ "mv_product_rank_monthly",
+ "product_metrics",
+ "outbox_event"
+ );
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ @Qualifier(RankingBatchJobConfig.JOB_NAME)
+ private Job job;
+
+ @Autowired
+ private RedisCleanUp redisCleanUp;
+
+ @Autowired
+ private MvProductRankStagingJpaRepository mvProductRankStagingJpaRepository;
+
+ @Autowired
+ private MvProductRankWeeklyJpaRepository mvProductRankWeeklyJpaRepository;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void cleanDb() {
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
+ for (String table : TRUNCATE_TABLES) {
+ jdbcTemplate.execute("TRUNCATE TABLE `" + table + "`");
+ }
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
+ }
+
+ @AfterEach
+ void tearDown() {
+ redisCleanUp.truncateAll();
+ }
+
+ @Test
+ @DisplayName("period 없이 실행하면 JobParameters 검증에서 실패한다.")
+ void launchJob_whenPeriodMissing_shouldFailValidation() {
+ jobLauncherTestUtils.setJob(job);
+
+ assertThatThrownBy(() -> jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15")
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters()
+ )).isInstanceOf(JobParametersInvalidException.class);
+ }
+
+ @Test
+ @DisplayName("유효한 period·periodKey면 Job이 COMPLETED 된다.")
+ void launchJob_whenValidParameters_shouldComplete() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ jdbcTemplate.update(
+ """
+ INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ 1L, 0L, 0L, 10L, java.sql.Timestamp.from(at)
+ );
+ jdbcTemplate.update(
+ """
+ INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ 2L, 0L, 0L, 5L, java.sql.Timestamp.from(at)
+ );
+ var execution = jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY")
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15")
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters()
+ );
+
+ var staging = mvProductRankStagingJpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc(
+ "WEEKLY",
+ "2026W15"
+ );
+ var weeklyMv = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15");
+
+ assertAll(
+ () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()),
+ () -> assertThat(execution.getStepExecutions()).hasSize(4),
+ () -> assertThat(staging).hasSize(2),
+ () -> assertThat(staging.get(0).getProductId()).isEqualTo(1L),
+ () -> assertThat(staging.get(0).getRankValue()).isEqualTo(1),
+ () -> assertThat(staging.get(1).getProductId()).isEqualTo(2L),
+ () -> assertThat(weeklyMv).hasSize(2),
+ () -> assertThat(weeklyMv.get(0).getProductId()).isEqualTo(1L),
+ () -> assertThat(weeklyMv.get(1).getProductId()).isEqualTo(2L)
+ );
+ }
+
+ @Test
+ @DisplayName("동일 period·periodKey로 Job을 두 번 실행해도 MV rank·product_id 결과가 동일하다(멱등).")
+ void launchJob_twiceSamePeriod_shouldProduceSameWeeklyMv() throws Exception {
+ jobLauncherTestUtils.setJob(job);
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ jdbcTemplate.update(
+ """
+ INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ 1L, 0L, 0L, 10L, java.sql.Timestamp.from(at)
+ );
+ jdbcTemplate.update(
+ """
+ INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ 2L, 0L, 0L, 5L, java.sql.Timestamp.from(at)
+ );
+ var params1 = new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY")
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15")
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters();
+ var first = jobLauncherTestUtils.launchJob(params1);
+ assertThat(first.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());
+
+ var afterFirst = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15");
+
+ var params2 = new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY")
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15")
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters();
+ var second = jobLauncherTestUtils.launchJob(params2);
+ assertThat(second.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode());
+
+ var afterSecond = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15");
+
+ assertAll(
+ () -> assertThat(afterSecond).hasSize(afterFirst.size()),
+ () -> assertThat(afterSecond.get(0).getProductId()).isEqualTo(afterFirst.get(0).getProductId()),
+ () -> assertThat(afterSecond.get(0).getRankValue()).isEqualTo(afterFirst.get(0).getRankValue()),
+ () -> assertThat(afterSecond.get(0).getScore()).isEqualByComparingTo(afterFirst.get(0).getScore()),
+ () -> assertThat(afterSecond.get(1).getProductId()).isEqualTo(afterFirst.get(1).getProductId()),
+ () -> assertThat(afterSecond.get(1).getRankValue()).isEqualTo(afterFirst.get(1).getRankValue()),
+ () -> assertThat(afterSecond.get(1).getScore()).isEqualByComparingTo(afterFirst.get(1).getScore())
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java
new file mode 100644
index 0000000000..ca3409ef51
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java
@@ -0,0 +1,76 @@
+package com.loopers.job.ranking;
+
+import com.loopers.batch.ranking.job.RankingBatchJobConfig;
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+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.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.test.JobLauncherTestUtils;
+import org.springframework.batch.test.context.SpringBatchTest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.context.TestPropertySource;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@SpringBatchTest
+@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME)
+@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class})
+class RankingBatchJobLockIntegrationTest {
+
+ private static final String PERIOD = "WEEKLY";
+ private static final String PERIOD_KEY = "2026W20";
+
+ @Autowired
+ private JobLauncherTestUtils jobLauncherTestUtils;
+
+ @Autowired
+ @Qualifier(RankingBatchJobConfig.JOB_NAME)
+ private Job job;
+
+ @Autowired
+ @Qualifier("redisTemplateMaster")
+ private RedisTemplate redisTemplate;
+
+ @Autowired
+ private RedisCleanUp redisCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ redisCleanUp.truncateAll();
+ }
+
+ @Test
+ @DisplayName("동일 period 락이 이미 있으면 첫 Step에서 실패한다.")
+ void launchJob_whenLockAlreadyHeld_shouldFail() throws Exception {
+ String lockKey = RankingBatchJobParameters.redisLockKey(PERIOD, PERIOD_KEY);
+ redisTemplate.opsForValue().set(lockKey, "other-owner", Duration.ofMinutes(10));
+
+ jobLauncherTestUtils.setJob(job);
+ var execution = jobLauncherTestUtils.launchJob(
+ new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, PERIOD)
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, PERIOD_KEY)
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters()
+ );
+
+ assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode());
+ assertThat(execution.getStepExecutions()).hasSize(1);
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java
new file mode 100644
index 0000000000..c5296cb084
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java
@@ -0,0 +1,136 @@
+package com.loopers.job.ranking;
+
+import com.loopers.batch.ranking.job.RankingBatchJobConfig;
+import com.loopers.domain.ranking.batch.RankingBatchJobParameters;
+import com.loopers.domain.ranking.batch.RankingStagingRankRow;
+import com.loopers.domain.ranking.batch.RankingStagingRepository;
+import com.loopers.infrastructure.ranking.mv.MvProductRankWeeklyJpaRepository;
+import com.loopers.testcontainers.MySqlTestContainersConfig;
+import com.loopers.testcontainers.RedisTestContainersConfig;
+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.Test;
+import org.springframework.batch.core.ExitStatus;
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobParametersBuilder;
+import org.springframework.batch.core.launch.JobLauncher;
+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.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest(properties = {
+ "spring.batch.job.enabled=false",
+ "outbox.relay.enabled=false"
+})
+@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME)
+@Import({
+ MySqlTestContainersConfig.class,
+ RedisTestContainersConfig.class,
+ RankingPublishOnlyJobTestConfig.class
+})
+class RankingPublishFailurePreservesMvIntegrationTest {
+
+ private static final List TRUNCATE_TABLES = List.of(
+ "mv_product_rank_staging",
+ "mv_product_rank_weekly",
+ "mv_product_rank_monthly",
+ "product_metrics",
+ "outbox_event"
+ );
+
+ @Autowired
+ private JobLauncher jobLauncher;
+
+ @Autowired
+ @Qualifier(RankingPublishOnlyJobTestConfig.BEAN_NAME)
+ private Job publishOnlyJob;
+
+ @Autowired
+ private RankingStagingRepository rankingStagingRepository;
+
+ @Autowired
+ private MvProductRankWeeklyJpaRepository weeklyJpaRepository;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private RedisCleanUp redisCleanUp;
+
+ @BeforeEach
+ void cleanDb() {
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0");
+ for (String table : TRUNCATE_TABLES) {
+ jdbcTemplate.execute("TRUNCATE TABLE `" + table + "`");
+ }
+ jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1");
+ }
+
+ @AfterEach
+ void tearDown() {
+ redisCleanUp.truncateAll();
+ }
+
+ @Test
+ @DisplayName("publish 검증 실패 시 기존 주간 MV 행이 유지된다(half-written 비노출).")
+ void launchPublishOnly_whenStagingInvalid_shouldFailWithoutReplacingMv() throws Exception {
+ String periodKey = "2026W15";
+ Instant at = Instant.parse("2026-04-10T00:00:00Z");
+ jdbcTemplate.update(
+ """
+ INSERT INTO mv_product_rank_weekly
+ (period_key, product_id, `rank`, score, version, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ periodKey,
+ 99L,
+ 1,
+ new BigDecimal("0.50"),
+ 1,
+ Timestamp.from(at)
+ );
+
+ rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", periodKey);
+ rankingStagingRepository.saveRankedRows(
+ "WEEKLY",
+ periodKey,
+ List.of(
+ new RankingStagingRankRow(1, 1L, BigDecimal.ONE),
+ new RankingStagingRankRow(3, 2L, BigDecimal.TEN)
+ )
+ );
+
+ JobExecution execution = jobLauncher.run(
+ publishOnlyJob,
+ new JobParametersBuilder()
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY")
+ .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, periodKey)
+ .addLong("run.id", System.currentTimeMillis())
+ .toJobParameters()
+ );
+
+ var rows = weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey);
+
+ assertAll(
+ () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()),
+ () -> assertThat(rows).hasSize(1),
+ () -> assertThat(rows.get(0).getProductId()).isEqualTo(99L),
+ () -> assertThat(rows.get(0).getRankValue()).isEqualTo(1),
+ () -> assertThat(rows.get(0).getScore()).isEqualByComparingTo(new BigDecimal("0.50"))
+ );
+ }
+}
diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java
new file mode 100644
index 0000000000..d028bb72c3
--- /dev/null
+++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java
@@ -0,0 +1,28 @@
+package com.loopers.job.ranking;
+
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.job.builder.JobBuilder;
+import org.springframework.batch.core.repository.JobRepository;
+import org.springframework.batch.core.Step;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * 스테이징 검증 실패 시 MV 비교를 위해 publish Step 단독 Job을 테스트에서 실행한다.
+ */
+@TestConfiguration
+public class RankingPublishOnlyJobTestConfig {
+
+ public static final String BEAN_NAME = "rankingPublishOnlyJob";
+
+ @Bean(BEAN_NAME)
+ public Job rankingPublishOnlyJob(
+ JobRepository jobRepository,
+ @Qualifier("rankingPublish") Step rankingPublishStep
+ ) {
+ return new JobBuilder("rankingPublishOnlyJob", jobRepository)
+ .start(rankingPublishStep)
+ .build();
+ }
+}
diff --git a/http/commerce-api/ranking-v1.http b/http/commerce-api/ranking-v1.http
index a1b686958c..a3be4829d1 100644
--- a/http/commerce-api/ranking-v1.http
+++ b/http/commerce-api/ranking-v1.http
@@ -25,3 +25,12 @@ POST http://localhost:8080/api/v1/rankings/snapshots?date=20260408
### 스냅샷으로 페이징 (위에서 받은 UUID로 교체)
GET http://localhost:8080/api/v1/rankings?date=20260408&page=1&size=20&rankingSnapshotId=00000000-0000-0000-0000-000000000000
+
+### 주간 랭킹 MV (periodKey: yyyyWww, dataSource: MV_WEEKLY)
+GET http://localhost:8080/api/v1/rankings?period=WEEKLY&periodKey=2026W15&page=1&size=20
+
+### 월간 랭킹 MV (periodKey: yyyyMM, dataSource: MV_MONTHLY)
+GET http://localhost:8080/api/v1/rankings?period=MONTHLY&periodKey=202604&page=1&size=20
+
+### period만 주고 periodKey 누락 (400 기대)
+GET http://localhost:8080/api/v1/rankings?period=WEEKLY&page=1&size=20