diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java index b41ee951ee..8e26ffd09e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyResolver.java @@ -4,16 +4,13 @@ import org.springframework.stereotype.Component; import java.time.Clock; -import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.time.temporal.WeekFields; @Component public class RankingKeyResolver { private static final DateTimeFormatter DAILY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); - private static final DateTimeFormatter MONTHLY_FORMAT = DateTimeFormatter.ofPattern("yyyyMM"); private final Clock clock; @@ -25,13 +22,9 @@ public String resolve(RankingPeriod period, LocalDate date, String groupName) { String base = switch (period) { case REALTIME -> "ranking:realtime"; case DAILY -> "ranking:daily:" + date.format(DAILY_FORMAT); - case WEEKLY -> { - WeekFields iso = WeekFields.ISO; - int year = date.get(iso.weekBasedYear()); - int week = date.get(iso.weekOfWeekBasedYear()); - yield String.format("ranking:weekly:%d%02d", year, week); - } - case MONTHLY -> "ranking:monthly:" + date.format(MONTHLY_FORMAT); + // LAST_7D / LAST_30D 는 anchor = 어제 (오늘 제외 롤링 윈도우) 기반 키 + case LAST_7D -> "ranking:last7d:" + anchorDateKey(date); + case LAST_30D -> "ranking:last30d:" + anchorDateKey(date); }; return base + ":" + groupName; } @@ -39,4 +32,16 @@ public String resolve(RankingPeriod period, LocalDate date, String groupName) { public String resolve(RankingPeriod period, LocalDate date) { return resolve(period, date, "control"); } + + /** + * 조회 기준일(오늘) 로부터 anchor_date (= 어제) 를 반환한다. + * 배치가 이 anchor 로 MV / Redis ZSET 을 만들므로 API 도 동일 키로 조회해야 한다. + */ + public LocalDate anchorDateOf(LocalDate date) { + return date.minusDays(1); + } + + private String anchorDateKey(LocalDate date) { + return anchorDateOf(date).format(DAILY_FORMAT); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index e9411bf8f3..54dc0cadb4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -2,14 +2,14 @@ import com.loopers.domain.ranking.RankEntry; import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.mv.MvRankEntry; +import com.loopers.domain.ranking.mv.MvRankingQueryRepository; import com.loopers.infrastructure.ranking.RankingRedisRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.temporal.TemporalAdjusters; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -21,15 +21,18 @@ public class RankingService { private final RankingRedisRepository rankingRedisRepository; private final RankingKeyResolver keyResolver; private final RankingFallbackAggregator fallbackAggregator; + private final MvRankingQueryRepository mvRankingQueryRepository; private final ExperimentGroupResolver experimentGroupResolver; public RankingService(RankingRedisRepository rankingRedisRepository, RankingKeyResolver keyResolver, RankingFallbackAggregator fallbackAggregator, + MvRankingQueryRepository mvRankingQueryRepository, ExperimentGroupResolver experimentGroupResolver) { this.rankingRedisRepository = rankingRedisRepository; this.keyResolver = keyResolver; this.fallbackAggregator = fallbackAggregator; + this.mvRankingQueryRepository = mvRankingQueryRepository; this.experimentGroupResolver = experimentGroupResolver; } @@ -47,11 +50,18 @@ public long getTotalCount(RankingPeriod period, LocalDate date, String group) { String key = keyResolver.resolve(period, date, group); try { Long count = rankingRedisRepository.getTotalCount(key); - return count == null ? 0 : count; + if (count != null && count > 0) { + return count; + } } catch (Exception e) { log.warn("Redis totalCount 조회 실패: {}", e.getMessage()); - return 0; } + // Redis miss 또는 0일 때 MV 카운트 fallback (LAST_7D / LAST_30D 만 해당) + return switch (period) { + case LAST_7D -> mvRankingQueryRepository.countLast7d(keyResolver.anchorDateOf(date), group); + case LAST_30D -> mvRankingQueryRepository.countLast30d(keyResolver.anchorDateOf(date), group); + default -> 0; + }; } public Integer getProductRank(Long productId, RankingPeriod period, LocalDate date) { @@ -67,14 +77,53 @@ private List loadRankEntries(RankingPeriod period, LocalDate date, in return fromRedis; } } catch (Exception e) { - log.warn("Redis 랭킹 조회 실패, DB fallback. period={}, date={}, group={}: {}", + log.warn("Redis 랭킹 조회 실패, fallback. period={}, date={}, group={}: {}", period, date, group, e.getMessage()); } - return fallbackFromDb(period, date, page, size); + return switch (period) { + // 롤링 랭킹: MV 테이블 직접 조회 (확정된 TOP N 을 그대로 투영, bucket SUM 재계산 아님) + case LAST_7D, LAST_30D -> fallbackFromMv(period, date, page, size, group); + // 실시간/일간: 기존 bucket 집계 재계산 경로 + case REALTIME, DAILY -> fallbackFromBucketAggregation(period, date, page, size); + }; + } + + private static final int MV_FALLBACK_MAX_DAYS = 3; + + private List fallbackFromMv(RankingPeriod period, LocalDate date, int page, int size, String group) { + try { + LocalDate anchorDate = keyResolver.anchorDateOf(date); + int offset = page * size; + + // 현재 anchor 의 MV 가 비어있으면 전일 anchor 로 자동 fallback (최대 3일). + // 배치 미실행 또는 해당 anchor 에 데이터가 없으면 비어있을 수 있음. + // "잘못된 랭킹" 보다 "어제 랭킹이라도 보여주기" 가 사용자 경험상 나음. + for (int retry = 0; retry < MV_FALLBACK_MAX_DAYS; retry++) { + List rows = switch (period) { + case LAST_7D -> mvRankingQueryRepository.findLast7d(anchorDate, group, offset, size); + case LAST_30D -> mvRankingQueryRepository.findLast30d(anchorDate, group, offset, size); + default -> List.of(); + }; + if (!rows.isEmpty()) { + if (retry > 0) { + log.info("MV fallback: 현재 anchor 비어있어 전일로 대체. period={}, 원래anchor={}, 사용anchor={}", + period, keyResolver.anchorDateOf(date), anchorDate); + } + return rows.stream() + .map(r -> new RankEntry(r.productId(), r.score(), r.rankPosition())) + .toList(); + } + anchorDate = anchorDate.minusDays(1); + } + return List.of(); + } catch (Exception e) { + log.error("MV fallback 실패. period={}, date={}, group={}", period, date, group, e); + return List.of(); + } } - private List fallbackFromDb(RankingPeriod period, LocalDate date, int page, int size) { + private List fallbackFromBucketAggregation(RankingPeriod period, LocalDate date, int page, int size) { try { LocalDateTime from = calculateFrom(period, date); LocalDateTime to = RankingDateUtils.kstDateToUtcBoundary(date.plusDays(1)); @@ -94,7 +143,7 @@ private List fallbackFromDb(RankingPeriod period, LocalDate date, int .map(e -> new RankEntry(e.getKey(), e.getValue(), rankCounter.getAndIncrement())) .toList(); } catch (Exception e) { - log.error("DB fallback도 실패. period={}, date={}", period, date, e); + log.error("bucket 집계 fallback 실패. period={}, date={}", period, date, e); return List.of(); } } @@ -109,14 +158,9 @@ private LocalDateTime calculateFrom(RankingPeriod period, LocalDate date) { java.time.ZoneOffset.UTC); } case DAILY -> RankingDateUtils.kstDateToUtcBoundary(date); - case WEEKLY -> { - LocalDate monday = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - yield RankingDateUtils.kstDateToUtcBoundary(monday); - } - case MONTHLY -> { - LocalDate firstOfMonth = date.withDayOfMonth(1); - yield RankingDateUtils.kstDateToUtcBoundary(firstOfMonth); - } + // LAST_7D / LAST_30D 는 MV fallback 경로라 여기 들어올 일 없음 + case LAST_7D, LAST_30D -> throw new IllegalStateException( + "rolling period fallback 은 MV 경로로만 처리되어야 한다: " + period); }; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java index 9cecf19598..1ed453d3a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java @@ -1,5 +1,16 @@ package com.loopers.domain.ranking; +/** + * 랭킹 조회 기간. + * + *

WEEKLY/MONTHLY 캘린더 경계는 "월요일 오전/매월 1일 오전에 표본이 1일치" 라는 빈약성 + * 문제가 있고, 실무에선 이커머스 랭킹을 롤링 N일 (오늘 제외) 로 구현하는 것이 일반적이다 + * (설계.md 프롤로그 + 데빈/케브/앨런 멘토링 결론). 본 API 는 배치가 만드는 롤링 MV 와 + * 일관되게 LAST_7D / LAST_30D 로 노출한다.

+ */ public enum RankingPeriod { - REALTIME, DAILY, WEEKLY, MONTHLY + REALTIME, + DAILY, + LAST_7D, + LAST_30D } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java new file mode 100644 index 0000000000..9498f99111 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking.mv; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +/** + * mv_product_rank_last_7d / mv_product_rank_last_30d 공통 PK. + * commerce-batch 가 생성·적재하는 MV 를 commerce-api 가 읽기 위한 스키마 미러. + */ +public class MvProductRankId implements Serializable { + + private LocalDate anchorDate; + private String weightGroup; + private Long productId; + + public MvProductRankId() { + } + + public MvProductRankId(LocalDate anchorDate, String weightGroup, Long productId) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MvProductRankId that)) return false; + return Objects.equals(anchorDate, that.anchorDate) + && Objects.equals(weightGroup, that.weightGroup) + && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(anchorDate, weightGroup, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java new file mode 100644 index 0000000000..5b45eadf28 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java @@ -0,0 +1,72 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "mv_product_rank_last_30d", + indexes = @Index( + name = "idx_last_30d_rank", + columnList = "anchor_date, weight_group, rank_position" + ) +) +@IdClass(MvProductRankId.class) +@Getter +public class MvProductRankLast30d { + + @Id + @Column(name = "anchor_date", nullable = false) + private LocalDate anchorDate; + + @Id + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected MvProductRankLast30d() { + } + + public MvProductRankLast30d(LocalDate anchorDate, String weightGroup, Long productId, + long viewCount, long likeCount, long salesAmount, + double score, int rankPosition, LocalDateTime createdAt) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + this.score = score; + this.rankPosition = rankPosition; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java new file mode 100644 index 0000000000..375a3a24e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java @@ -0,0 +1,76 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 롤링 7일 랭킹 확정 MV 의 commerce-api 측 읽기 모델. + * commerce-batch 가 쓰기 소유자이며, 여기서는 조회만 한다. + */ +@Entity +@Table( + name = "mv_product_rank_last_7d", + indexes = @Index( + name = "idx_last_7d_rank", + columnList = "anchor_date, weight_group, rank_position" + ) +) +@IdClass(MvProductRankId.class) +@Getter +public class MvProductRankLast7d { + + @Id + @Column(name = "anchor_date", nullable = false) + private LocalDate anchorDate; + + @Id + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected MvProductRankLast7d() { + } + + public MvProductRankLast7d(LocalDate anchorDate, String weightGroup, Long productId, + long viewCount, long likeCount, long salesAmount, + double score, int rankPosition, LocalDateTime createdAt) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + this.score = score; + this.rankPosition = rankPosition; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java new file mode 100644 index 0000000000..89f8662b27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankEntry.java @@ -0,0 +1,8 @@ +package com.loopers.domain.ranking.mv; + +/** + * MV 조회 결과의 최소 표현 — product_id + score + rank_position. + * 나머지 컬럼(view/like/sales) 은 API 응답에 필요하지 않으므로 투영하지 않는다. + */ +public record MvRankEntry(Long productId, double score, int rankPosition) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java new file mode 100644 index 0000000000..353465632f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MvRankingQueryRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking.mv; + +import java.time.LocalDate; +import java.util.List; + +/** + * LAST_7D / LAST_30D MV 에 대한 조회 전용 Repository. + * Redis identity cache miss 시 fallback 경로가 여기로 진입한다. + */ +public interface MvRankingQueryRepository { + + // Query + List findLast7d(LocalDate anchorDate, String weightGroup, int offset, int limit); + + List findLast30d(LocalDate anchorDate, String weightGroup, int offset, int limit); + + long countLast7d(LocalDate anchorDate, String weightGroup); + + long countLast30d(LocalDate anchorDate, String weightGroup); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java new file mode 100644 index 0000000000..699a5bd40a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvRankingQueryRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.mv.MvRankEntry; +import com.loopers.domain.ranking.mv.MvRankingQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; + +/** + * JdbcTemplate 기반 MV 조회. JPA Entity 로 읽지 않는 이유: + * - 응답에는 product_id/score/rank_position 만 필요 (나머지 컬럼 투영 불필요) + * - 영속성 컨텍스트가 MV 를 관리할 가치 없음 (쓰기는 commerce-batch 가 소유) + * - 단순 투영 SELECT 이므로 JDBC 가 가장 명료함 (설계.md "Writer 쪽 JPA 함정" 과 같은 결) + */ +@Repository +@RequiredArgsConstructor +public class MvRankingQueryRepositoryImpl implements MvRankingQueryRepository { + + private static final String SELECT_LAST_7D = """ + SELECT product_id, score, rank_position + FROM mv_product_rank_last_7d + WHERE anchor_date = ? AND weight_group = ? + ORDER BY rank_position + LIMIT ? OFFSET ? + """; + + private static final String SELECT_LAST_30D = """ + SELECT product_id, score, rank_position + FROM mv_product_rank_last_30d + WHERE anchor_date = ? AND weight_group = ? + ORDER BY rank_position + LIMIT ? OFFSET ? + """; + + private static final String COUNT_LAST_7D = + "SELECT COUNT(*) FROM mv_product_rank_last_7d WHERE anchor_date = ? AND weight_group = ?"; + + private static final String COUNT_LAST_30D = + "SELECT COUNT(*) FROM mv_product_rank_last_30d WHERE anchor_date = ? AND weight_group = ?"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public List findLast7d(LocalDate anchorDate, String weightGroup, int offset, int limit) { + return jdbcTemplate.query(SELECT_LAST_7D, + (rs, rn) -> new MvRankEntry( + rs.getLong("product_id"), + rs.getDouble("score"), + rs.getInt("rank_position")), + Date.valueOf(anchorDate), weightGroup, limit, offset); + } + + @Override + public List findLast30d(LocalDate anchorDate, String weightGroup, int offset, int limit) { + return jdbcTemplate.query(SELECT_LAST_30D, + (rs, rn) -> new MvRankEntry( + rs.getLong("product_id"), + rs.getDouble("score"), + rs.getInt("rank_position")), + Date.valueOf(anchorDate), weightGroup, limit, offset); + } + + @Override + public long countLast7d(LocalDate anchorDate, String weightGroup) { + Long c = jdbcTemplate.queryForObject(COUNT_LAST_7D, Long.class, Date.valueOf(anchorDate), weightGroup); + return c == null ? 0L : c; + } + + @Override + public long countLast30d(LocalDate anchorDate, String weightGroup) { + Long c = jdbcTemplate.queryForObject(COUNT_LAST_30D, Long.class, Date.valueOf(anchorDate), weightGroup); + return c == null ? 0L : c; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java index 08cf845a64..89469ce08c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingKeyResolverTest.java @@ -48,33 +48,40 @@ class 일간_키 { } @Nested - class 주간_키 { + class 롤링_7일_키 { @Test - void ISO_주차가_포함된_주간_키를_생성한다() { - // 2026-04-10 금요일 → ISO week 15 - String key = resolver.resolve(RankingPeriod.WEEKLY, LocalDate.of(2026, 4, 10), "control"); + void 어제_기준_anchor_로_last7d_키를_생성한다() { + // 조회 기준일 2026-04-15 → anchor_date = 2026-04-14 (오늘 제외) + String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "control"); - assertThat(key).isEqualTo("ranking:weekly:202615:control"); + assertThat(key).isEqualTo("ranking:last7d:20260414:control"); } @Test - void 연초_주차가_올바르게_계산된다() { - // 2026-01-01 목요일 → ISO week 1 - String key = resolver.resolve(RankingPeriod.WEEKLY, LocalDate.of(2026, 1, 1), "control"); + void 실험_그룹별로_독립된_키를_생성한다() { + String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "experiment_a"); - assertThat(key).isEqualTo("ranking:weekly:202601:control"); + assertThat(key).isEqualTo("ranking:last7d:20260414:experiment_a"); + } + + @Test + void 월_경계를_걸쳐도_음수_날짜없이_안전하게_계산된다() { + // 조회 기준일 2026-01-01 → anchor_date = 2025-12-31 + String key = resolver.resolve(RankingPeriod.LAST_7D, LocalDate.of(2026, 1, 1), "control"); + + assertThat(key).isEqualTo("ranking:last7d:20251231:control"); } } @Nested - class 월간_키 { + class 롤링_30일_키 { @Test - void 연월이_포함된_월간_키를_생성한다() { - String key = resolver.resolve(RankingPeriod.MONTHLY, LocalDate.of(2026, 4, 10), "control"); + void 어제_기준_anchor_로_last30d_키를_생성한다() { + String key = resolver.resolve(RankingPeriod.LAST_30D, LocalDate.of(2026, 4, 15), "control"); - assertThat(key).isEqualTo("ranking:monthly:202604:control"); + assertThat(key).isEqualTo("ranking:last30d:20260414:control"); } } @@ -88,4 +95,15 @@ class 기본_그룹 { assertThat(key).isEqualTo("ranking:daily:20260410:control"); } } + + @Nested + class anchor_date_계산 { + + @Test + void 오늘의_anchor_는_어제이다() { + LocalDate anchor = resolver.anchorDateOf(LocalDate.of(2026, 4, 15)); + + assertThat(anchor).isEqualTo(LocalDate.of(2026, 4, 14)); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java new file mode 100644 index 0000000000..eae66e985c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceMvFallbackTest.java @@ -0,0 +1,123 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.ranking.RankEntry; +import com.loopers.domain.ranking.RankingPeriod; +import com.loopers.domain.ranking.mv.MvRankEntry; +import com.loopers.domain.ranking.mv.MvRankingQueryRepository; +import com.loopers.infrastructure.ranking.RankingRedisRepository; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Redis miss 시 LAST_7D / LAST_30D 는 MV 테이블로 fallback 한다. + */ +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RankingServiceMvFallbackTest { + + private final RankingRedisRepository redisRepository = Mockito.mock(RankingRedisRepository.class); + private final RankingFallbackAggregator bucketFallback = Mockito.mock(RankingFallbackAggregator.class); + private final MvRankingQueryRepository mvRepository = Mockito.mock(MvRankingQueryRepository.class); + private final ExperimentGroupResolver groupResolver = Mockito.mock(ExperimentGroupResolver.class); + private final RankingKeyResolver keyResolver = new RankingKeyResolver(Clock.system(ZoneId.of("Asia/Seoul"))); + + private final RankingService service = new RankingService( + redisRepository, keyResolver, bucketFallback, mvRepository, groupResolver); + + @Test + void Redis_miss_시_LAST_7D_는_MV_테이블에서_어제_anchor_로_조회한다() { + // given: Redis 빈 응답 + when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of()); + when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 14)), eq("control"), eq(0), eq(20))) + .thenReturn(List.of( + new MvRankEntry(1L, 99.9, 1), + new MvRankEntry(2L, 88.8, 2) + )); + + // when: 조회 기준일 2026-04-15 → anchor_date = 2026-04-14 + List result = service.getRankEntries( + RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control"); + + // then: MV 결과를 rank_position 순으로 투영 + assertThat(result).extracting(RankEntry::productId).containsExactly(1L, 2L); + assertThat(result).extracting(RankEntry::rank).containsExactly(1, 2); + assertThat(result).extracting(RankEntry::score).containsExactly(99.9, 88.8); + Mockito.verify(bucketFallback, Mockito.never()).aggregate(any(), any()); + } + + @Test + void LAST_30D_도_동일한_MV_fallback_경로를_탄다() { + when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of()); + when(mvRepository.findLast30d(eq(LocalDate.of(2026, 4, 14)), eq("control"), eq(0), eq(10))) + .thenReturn(List.of(new MvRankEntry(5L, 55.5, 1))); + + List result = service.getRankEntries( + RankingPeriod.LAST_30D, LocalDate.of(2026, 4, 15), 0, 10, "control"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(5L); + } + + @Test + void Redis_가_응답하면_MV_는_호출되지_않는다() { + when(redisRepository.getRankings(anyString(), anyInt(), anyInt())) + .thenReturn(List.of(new RankEntry(7L, 42.0, 1))); + + List result = service.getRankEntries( + RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(7L); + Mockito.verifyNoInteractions(mvRepository); + } + + @Test + void 현재_anchor_MV_가_비어있으면_전일_anchor_로_자동_fallback_한다() { + when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of()); + // 오늘 anchor (4/14) 비어있음 → 전일 (4/13) 에 데이터 있음 + when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 14)), anyString(), anyInt(), anyInt())) + .thenReturn(List.of()); + when(mvRepository.findLast7d(eq(LocalDate.of(2026, 4, 13)), anyString(), anyInt(), anyInt())) + .thenReturn(List.of(new MvRankEntry(1L, 50.0, 1))); + + List result = service.getRankEntries( + RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(1L); + } + + @Test + void 전일_fallback_도_3일간_비어있으면_빈_리스트를_반환한다() { + when(redisRepository.getRankings(anyString(), anyInt(), anyInt())).thenReturn(List.of()); + when(mvRepository.findLast7d(any(), anyString(), anyInt(), anyInt())).thenReturn(List.of()); + + List result = service.getRankEntries( + RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), 0, 20, "control"); + + assertThat(result).isEmpty(); + } + + @Test + void totalCount_도_Redis_miss_시_MV_카운트로_fallback_한다() { + when(redisRepository.getTotalCount(anyString())).thenReturn(0L); + when(mvRepository.countLast7d(eq(LocalDate.of(2026, 4, 14)), eq("control"))).thenReturn(100L); + + long total = service.getTotalCount(RankingPeriod.LAST_7D, LocalDate.of(2026, 4, 15), "control"); + + assertThat(total).isEqualTo(100L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java index 723f7a7ef7..ed2e5240e1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingApiE2ETest.java @@ -14,6 +14,7 @@ import org.springframework.context.annotation.Import; import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -47,6 +48,9 @@ class RankingApiE2ETest { @Autowired private RedisTemplate redisTemplate; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired private Clock clock; @@ -201,28 +205,96 @@ class 기간별_조회 { } @Test - void 주간_기간으로_조회하면_200_응답한다() { - ResponseEntity> response = getRankings("?period=WEEKLY"); + void 롤링_7일_기간으로_조회하면_200_응답한다() { + ResponseEntity> response = getRankings("?period=LAST_7D"); assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().period().name()).isEqualTo("WEEKLY") + () -> assertThat(response.getBody().data().period().name()).isEqualTo("LAST_7D") ); } @Test - void 월간_기간으로_조회하면_200_응답한다() { - ResponseEntity> response = getRankings("?period=MONTHLY"); + void 롤링_30일_기간으로_조회하면_200_응답한다() { + ResponseEntity> response = getRankings("?period=LAST_30D"); assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), - () -> assertThat(response.getBody().data().period().name()).isEqualTo("MONTHLY") + () -> assertThat(response.getBody().data().period().name()).isEqualTo("LAST_30D") + ); + } + } + + @Nested + class 롤링_랭킹_MV_fallback { + + @Test + void LAST_7D_는_Redis_가_비어있고_MV_에만_데이터가_있으면_MV_에서_조회하여_응답한다() { + Long brandId = fixture.registerBrand("나이키", "스포츠"); + Long productId1 = fixture.registerProduct(brandId, "상품A", BigDecimal.valueOf(10000), 100, "설명A"); + Long productId2 = fixture.registerProduct(brandId, "상품B", BigDecimal.valueOf(20000), 100, "설명B"); + + // 조회 기준일 = 2026-04-15 → anchor_date = 2026-04-14 + String targetDate = "20260415"; + insertMvLast7d(java.sql.Date.valueOf("2026-04-14"), "control", productId1, 99.9, 1); + insertMvLast7d(java.sql.Date.valueOf("2026-04-14"), "control", productId2, 50.0, 2); + // Redis 는 비워둠 → MV fallback 경로 + + ResponseEntity> response = + getRankings("?period=LAST_7D&date=" + targetDate); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("상품A"), + () -> assertThat(response.getBody().data().items().get(0).score()).isEqualTo(99.9), + () -> assertThat(response.getBody().data().items().get(1).rank()).isEqualTo(2), + () -> assertThat(response.getBody().data().items().get(1).productName()).isEqualTo("상품B") + ); + } + + @Test + void LAST_30D_도_동일하게_MV_fallback_경로로_응답한다() { + Long brandId = fixture.registerBrand("나이키", "스포츠"); + Long productId = fixture.registerProduct(brandId, "상품A", BigDecimal.valueOf(10000), 100, "설명A"); + + String targetDate = "20260415"; + insertMvLast30d(java.sql.Date.valueOf("2026-04-14"), "control", productId, 77.7, 1); + + ResponseEntity> response = + getRankings("?period=LAST_30D&date=" + targetDate); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).score()).isEqualTo(77.7) ); } } // --- 헬퍼 메서드 --- + private void insertMvLast7d(java.sql.Date anchorDate, String group, long productId, double score, int rank) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_last_7d " + + "(anchor_date, weight_group, product_id, view_count, like_count, sales_amount, " + + " score, rank_position, created_at) " + + "VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?)", + anchorDate, group, productId, score, rank, + java.sql.Timestamp.valueOf(java.time.LocalDateTime.of(2026, 4, 15, 1, 0))); + } + + private void insertMvLast30d(java.sql.Date anchorDate, String group, long productId, double score, int rank) { + jdbcTemplate.update( + "INSERT INTO mv_product_rank_last_30d " + + "(anchor_date, weight_group, product_id, view_count, like_count, sales_amount, " + + " score, rank_position, created_at) " + + "VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?)", + anchorDate, group, productId, score, rank, + java.sql.Timestamp.valueOf(java.time.LocalDateTime.of(2026, 4, 15, 1, 0))); + } + private ResponseEntity> getRankings(String queryString) { return testRestTemplate.exchange( ENDPOINT + queryString, diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index b22b6477cc..165ad8207f 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -19,3 +19,25 @@ dependencies { testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) } + +// 평소 ./gradlew test 에서는 @Tag("benchmark") 테스트를 제외한다. +// 측정용 실행은 -PrunBenchmark=true 또는 별도 task ":apps:commerce-batch:benchmarkTest" 사용. +tasks.named("test") { + useJUnitPlatform { + excludeTags("benchmark") + } +} + +tasks.register("benchmarkTest") { + description = "랭킹 배치 선형성/스파이크 측정 (오래 걸림)" + group = "verification" + useJUnitPlatform { + includeTags("benchmark") + } + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + // 측정 결과가 stdout 으로 흘러나오도록 standard output 강제 노출 + testLogging { + showStandardStreams = true + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java new file mode 100644 index 0000000000..73e3430ccd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RollingRankingJobConfig.java @@ -0,0 +1,74 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.batch.job.ranking.step.promote.PromoteTopToMvStepConfig; +import com.loopers.batch.job.ranking.step.redis.RedisRefreshStepConfig; +import com.loopers.batch.job.ranking.step.score.ScoreAggregationStepConfig; +import com.loopers.batch.job.ranking.step.stage.StageLikeMetricsStepConfig; +import com.loopers.batch.job.ranking.step.stage.StageOrderMetricsStepConfig; +import com.loopers.batch.job.ranking.step.stage.StageViewMetricsStepConfig; +import com.loopers.batch.job.ranking.step.truncate.TruncateStagingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 롤링 7일 / 30일 랭킹 배치 Job 구성. + * + *

Step 체인: 0 → 1 → 2 → 3 → 4 → 5 → 6

+ */ +@Configuration +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RollingRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +public class RollingRankingJobConfig { + + public static final String JOB_NAME = "rollingRankingJob"; + public static final String STEP_TRUNCATE_STAGING = "truncateStagingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final RankingJobParametersListener rankingJobParametersListener; + private final TruncateStagingTasklet truncateStagingTasklet; + + @Bean(JOB_NAME) + public Job rollingRankingJob( + @Qualifier(StageViewMetricsStepConfig.STEP_NAME) Step stageViewMetricsStep, + @Qualifier(StageLikeMetricsStepConfig.STEP_NAME) Step stageLikeMetricsStep, + @Qualifier(StageOrderMetricsStepConfig.STEP_NAME) Step stageOrderMetricsStep, + @Qualifier(ScoreAggregationStepConfig.STEP_NAME) Step scoreAggregationStep, + @Qualifier(PromoteTopToMvStepConfig.STEP_NAME) Step promoteTopToMvStep, + @Qualifier(RedisRefreshStepConfig.STEP_NAME) Step redisRefreshStep + ) { + return new JobBuilder(JOB_NAME, jobRepository) + .listener(jobListener) + .listener(rankingJobParametersListener) + .start(truncateStagingStep()) + .next(stageViewMetricsStep) + .next(stageLikeMetricsStep) + .next(stageOrderMetricsStep) + .next(scoreAggregationStep) + .next(promoteTopToMvStep) + .next(redisRefreshStep) + .build(); + } + + @Bean(STEP_TRUNCATE_STAGING) + public Step truncateStagingStep() { + return new StepBuilder(STEP_TRUNCATE_STAGING, jobRepository) + .tasklet(truncateStagingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java new file mode 100644 index 0000000000..5cb8f272d9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RankingJobParametersListener.java @@ -0,0 +1,104 @@ +package com.loopers.batch.job.ranking.param; + +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Job 이 의존하는 모든 외부 입력을 최초 실행 시점에 ExecutionContext 에 동결한다. + * + *

동결 대상: + *

    + *
  • {@code anchorDate} → 롤링 윈도우 경계 (last7dStart/End, last30dStart/End)
  • + *
  • {@code activeWeightGroups} → 활성 weight_group 이름 + 가중치 스냅샷
  • + *
+ * + *

재시작 시 ExecutionContext 는 BATCH_JOB_EXECUTION_CONTEXT 테이블에서 복원되므로, + * beforeJob 은 최초 실행 시에만 기록 (이미 값이 있으면 skip). 이로써 restart 사이에 + * ranking_weight_config 가 변경되어도 Job 은 최초 시작 시점의 스냅샷만 사용한다 + * (설계.md Bounded 원칙).

+ */ +@Component +@RequiredArgsConstructor +public class RankingJobParametersListener implements JobExecutionListener { + + public static final String PARAM_ANCHOR_DATE = "anchorDate"; + + // 롤링 윈도우 경계 + public static final String CTX_ANCHOR_DATE_KEY = "anchorDateKey"; + public static final String CTX_LAST_7D_START = "last7dStart"; + public static final String CTX_LAST_7D_END = "last7dEnd"; + public static final String CTX_LAST_30D_START = "last30dStart"; + public static final String CTX_LAST_30D_END = "last30dEnd"; + + // weight_group 스냅샷 + public static final String CTX_ACTIVE_WEIGHT_GROUPS = "activeWeightGroups"; + private static final String CTX_WEIGHT_PREFIX = "w."; + + private final WeightConfigRepository weightConfigRepository; + + @Override + public void beforeJob(JobExecution jobExecution) { + ExecutionContext ctx = jobExecution.getExecutionContext(); + if (ctx.containsKey(CTX_ANCHOR_DATE_KEY)) { + return; + } + + // 1. 롤링 윈도우 경계 동결 + String anchorDateParam = jobExecution.getJobParameters().getString(PARAM_ANCHOR_DATE); + RollingWindow window = RollingWindowResolver.resolve(anchorDateParam); + + ctx.putString(CTX_ANCHOR_DATE_KEY, window.anchorDateKey()); + ctx.putString(CTX_LAST_7D_START, window.last7dStart().toString()); + ctx.putString(CTX_LAST_7D_END, window.last7dEnd().toString()); + ctx.putString(CTX_LAST_30D_START, window.last30dStart().toString()); + ctx.putString(CTX_LAST_30D_END, window.last30dEnd().toString()); + + // 2. weight_group 스냅샷 동결 + List configs = weightConfigRepository.findAllByActiveTrue(); + if (configs.isEmpty()) { + configs = List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + } + configs = configs.stream() + .sorted(Comparator.comparing(WeightConfig::getGroupName)) + .toList(); + + ctx.putString(CTX_ACTIVE_WEIGHT_GROUPS, + configs.stream().map(WeightConfig::getGroupName).collect(Collectors.joining(","))); + for (WeightConfig c : configs) { + String prefix = CTX_WEIGHT_PREFIX + c.getGroupName() + "."; + ctx.putDouble(prefix + "wView", c.getWView()); + ctx.putDouble(prefix + "wLike", c.getWLike()); + ctx.putDouble(prefix + "wOrder", c.getWOrder()); + } + } + + /** + * ExecutionContext 에서 동결된 weight_group 스냅샷을 복원한다. + * Step 에서 DB 직접 조회 대신 이 메서드를 사용해야 Bounded 원칙이 유지된다. + */ + public static List restoreWeightConfigs(ExecutionContext ctx) { + String groups = ctx.getString(CTX_ACTIVE_WEIGHT_GROUPS); + if (groups == null || groups.isBlank()) { + return List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + } + List result = new ArrayList<>(); + for (String groupName : groups.split(",")) { + String prefix = CTX_WEIGHT_PREFIX + groupName + "."; + double wView = ctx.getDouble(prefix + "wView"); + double wLike = ctx.getDouble(prefix + "wLike"); + double wOrder = ctx.getDouble(prefix + "wOrder"); + result.add(new WeightConfig(groupName, wView, wLike, wOrder, 0, true)); + } + return result; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java new file mode 100644 index 0000000000..b70370cbc9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindow.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.param; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * anchor_date 를 기준으로 계산된 LAST_7D / LAST_30D 롤링 윈도우 경계. + * + * - 오늘은 항상 제외된다 (anchor = 어제). + * - 경계는 exclusive: bucket_time >= start AND bucket_time < end. + * - last7dEnd 와 last30dEnd 는 동일 (= anchor + 1일 00:00). 둘 다 "오늘 0시" 를 상한으로 둠. + */ +public record RollingWindow( + LocalDate anchorDate, + String anchorDateKey, // yyyyMMdd + LocalDateTime last7dStart, + LocalDateTime last7dEnd, + LocalDateTime last30dStart, + LocalDateTime last30dEnd +) { + private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public static RollingWindow of(LocalDate anchorDate) { + LocalDateTime last7dStart = anchorDate.minusDays(6).atStartOfDay(); + LocalDateTime last7dEnd = anchorDate.plusDays(1).atStartOfDay(); + LocalDateTime last30dStart = anchorDate.minusDays(29).atStartOfDay(); + LocalDateTime last30dEnd = anchorDate.plusDays(1).atStartOfDay(); + + return new RollingWindow( + anchorDate, + anchorDate.format(KEY_FORMAT), + last7dStart, + last7dEnd, + last30dStart, + last30dEnd + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java new file mode 100644 index 0000000000..e8913d7a64 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/param/RollingWindowResolver.java @@ -0,0 +1,41 @@ +package com.loopers.batch.job.ranking.param; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoField; + +/** + * JobParameter 로 받은 anchorDate(yyyyMMdd) 를 {@link RollingWindow} 로 변환한다. + * 외부 플랫폼(Cron/K8s/Airflow) 이 주입한 값만 신뢰하며 {@code LocalDate.now()} 같은 + * 트리거 시간 의존은 허용하지 않는다 (트리거 시간 != 데이터 경계). + */ +public final class RollingWindowResolver { + + // STRICT resolver 로 "20260230" 같은 무효 날짜를 예외 처리 (SMART 는 관대하게 보정함) + private static final DateTimeFormatter KEY_FORMAT = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR, 4) + .appendValue(ChronoField.MONTH_OF_YEAR, 2) + .appendValue(ChronoField.DAY_OF_MONTH, 2) + .toFormatter() + .withResolverStyle(ResolverStyle.STRICT); + + private RollingWindowResolver() { + } + + public static RollingWindow resolve(String anchorDateKey) { + if (anchorDateKey == null || anchorDateKey.isBlank()) { + throw new IllegalArgumentException("anchorDate 파라미터가 필요합니다 (yyyyMMdd)"); + } + LocalDate anchorDate; + try { + anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "anchorDate 포맷이 잘못되었습니다 (yyyyMMdd): " + anchorDateKey, e); + } + return RollingWindow.of(anchorDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java new file mode 100644 index 0000000000..5e3d914b1b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvStepConfig.java @@ -0,0 +1,30 @@ +package com.loopers.batch.job.ranking.step.promote; + +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class PromoteTopToMvStepConfig { + + public static final String STEP_NAME = "promoteTopToMvStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final StepMonitorListener stepMonitorListener; + private final PromoteTopToMvTasklet promoteTopToMvTasklet; + + @Bean(STEP_NAME) + public Step promoteTopToMvStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(promoteTopToMvTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java new file mode 100644 index 0000000000..e30b496e38 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/promote/PromoteTopToMvTasklet.java @@ -0,0 +1,126 @@ +package com.loopers.batch.job.ranking.step.promote; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.batch.job.ranking.step.stage.StagingAggregationProcessor; +import com.loopers.domain.ranking.audit.BatchAuditLog; +import com.loopers.domain.ranking.audit.BatchAuditLogRepository; +import com.loopers.domain.ranking.weight.WeightConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Step 5 — MV 의 해당 anchor 를 DELETE 한 뒤 2차 스테이징에서 TOP 100 을 INSERT + 실행 이력 기록. + * + *

DELETE + INSERT + audit_log 기록이 **단일 TX** 안에서 실행되므로 MVCC 에 의해 + * 외부 세션(API) 은 커밋 전까지 이전 MV 를, 커밋 후에는 새 MV 만 봄. + * "MV 가 비어있는 순간" 이 물리적으로 노출되지 않는다 (원자 교체).

+ */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class PromoteTopToMvTasklet implements Tasklet { + + public static final int TOP_N = 100; + private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private static final String INSERT_SQL_LAST_7D = insertSqlFor("mv_product_rank_last_7d"); + private static final String INSERT_SQL_LAST_30D = insertSqlFor("mv_product_rank_last_30d"); + + private final JdbcTemplate jdbcTemplate; + private final BatchAuditLogRepository auditLogRepository; + + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") + private String anchorDateKey; + + @Value("#{stepExecution.jobExecution.id}") + private Long jobExecutionId; + + @Override + @Transactional + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT); + Date sqlDate = Date.valueOf(anchorDate); + List configs = RankingJobParametersListener.restoreWeightConfigs( + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext()); + + // 1. DELETE — 이전 MV 제거 (같은 TX 안이라 외부에 아직 안 보임) + int deleted7d = jdbcTemplate.update( + "DELETE FROM mv_product_rank_last_7d WHERE anchor_date = ?", sqlDate); + int deleted30d = jdbcTemplate.update( + "DELETE FROM mv_product_rank_last_30d WHERE anchor_date = ?", sqlDate); + + // 2. INSERT — TOP 100 적재 + Timestamp createdAt = Timestamp.valueOf(LocalDateTime.now()); + int totalInserted = 0; + for (WeightConfig config : configs) { + int inserted7d = promote(INSERT_SQL_LAST_7D, StagingAggregationProcessor.PERIOD_LAST_7D, + anchorDate, config.getGroupName(), createdAt); + int inserted30d = promote(INSERT_SQL_LAST_30D, StagingAggregationProcessor.PERIOD_LAST_30D, + anchorDate, config.getGroupName(), createdAt); + totalInserted += inserted7d + inserted30d; + + // 3. 실행 이력 기록 — MV 적재와 같은 TX 에서 커밋 + auditLogRepository.save(BatchAuditLog.ok( + jobExecutionId, anchorDate, + StagingAggregationProcessor.PERIOD_LAST_7D, config.getGroupName(), inserted7d)); + auditLogRepository.save(BatchAuditLog.ok( + jobExecutionId, anchorDate, + StagingAggregationProcessor.PERIOD_LAST_30D, config.getGroupName(), inserted30d)); + } + + log.info("[STEP=promoteTopToMvStep] anchorDate={} deleted7d={} deleted30d={} inserted={}", + anchorDate, deleted7d, deleted30d, totalInserted); + + contribution.incrementWriteCount(deleted7d + deleted30d + totalInserted); + return RepeatStatus.FINISHED; + } + + private int promote(String sql, String periodType, LocalDate anchorDate, + String weightGroup, Timestamp createdAt) { + return jdbcTemplate.update( + sql, + periodType, anchorDateKey, weightGroup, + Date.valueOf(anchorDate), createdAt, + TOP_N + ); + } + + private static String insertSqlFor(String mvTable) { + return """ + INSERT INTO %s + (anchor_date, weight_group, product_id, + view_count, like_count, sales_amount, + score, rank_position, created_at) + WITH ranked AS ( + SELECT weight_group, product_id, + view_count, like_count, sales_amount, score, + ROW_NUMBER() OVER (ORDER BY score DESC, product_id ASC) AS rn + FROM staging_ranking_scored + WHERE period_type = ? + AND period_key = ? + AND weight_group = ? + ) + SELECT ?, weight_group, product_id, + view_count, like_count, sales_amount, score, rn, ? + FROM ranked + WHERE rn <= ? + """.formatted(mvTable); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java new file mode 100644 index 0000000000..5da7633898 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshStepConfig.java @@ -0,0 +1,30 @@ +package com.loopers.batch.job.ranking.step.redis; + +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RedisRefreshStepConfig { + + public static final String STEP_NAME = "redisRefreshStep"; + + private final JobRepository jobRepository; + private final StepMonitorListener stepMonitorListener; + private final RedisRefreshTasklet redisRefreshTasklet; + + @Bean(STEP_NAME) + public Step redisRefreshStep() { + // Redis 조작은 트랜잭션 매니저가 필요하지 않으므로 Resourceless 사용 + return new StepBuilder(STEP_NAME, jobRepository) + .tasklet(redisRefreshTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java new file mode 100644 index 0000000000..50e3764b62 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/redis/RedisRefreshTasklet.java @@ -0,0 +1,111 @@ +package com.loopers.batch.job.ranking.step.redis; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.weight.WeightConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Date; +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Step 7 — MV 의 확정된 TOP 100 을 Redis ZSET identity cache 로 복제한다. + * + *

Shadow key 에 ZADD 후 RENAME 으로 원자적 교체 → 조회 중 깜빡임 없음. + * Redis 는 MV 의 identity mirror (score·순서 동일) — 새 계산은 없다.

+ * + *

Step 7 실패는 치명적이지 않음 — MV 자체는 영속되어 있고 + * 조회 API 가 MV fallback 으로 동일 응답을 만들 수 있음.

+ */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class RedisRefreshTasklet implements Tasklet { + + private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Duration TTL = Duration.ofDays(3); + + private static final String SQL_LAST_7D = """ + SELECT product_id, score + FROM mv_product_rank_last_7d + WHERE anchor_date = ? AND weight_group = ? + ORDER BY rank_position + """; + + private static final String SQL_LAST_30D = """ + SELECT product_id, score + FROM mv_product_rank_last_30d + WHERE anchor_date = ? AND weight_group = ? + ORDER BY rank_position + """; + + private final JdbcTemplate jdbcTemplate; + private final RedisTemplate redisTemplate; + + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") + private String anchorDateKey; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + LocalDate anchorDate = LocalDate.parse(anchorDateKey, KEY_FORMAT); + List configs = RankingJobParametersListener.restoreWeightConfigs( + chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext()); + + int totalAdded = 0; + for (WeightConfig config : configs) { + String group = config.getGroupName(); + totalAdded += refreshZSet( + "ranking:last7d:" + anchorDateKey + ":" + group, + SQL_LAST_7D, Date.valueOf(anchorDate), group); + totalAdded += refreshZSet( + "ranking:last30d:" + anchorDateKey + ":" + group, + SQL_LAST_30D, Date.valueOf(anchorDate), group); + } + + log.info("[STEP=redisRefreshStep] anchorDate={} groups={} zaddCount={}", + anchorDate, configs.size(), totalAdded); + return RepeatStatus.FINISHED; + } + + private int refreshZSet(String key, String sql, Date anchorDate, String weightGroup) { + List rows = jdbcTemplate.query(sql, + (rs, rn) -> new ProductScore(rs.getLong("product_id"), rs.getDouble("score")), + anchorDate, weightGroup); + + if (rows.isEmpty()) { + redisTemplate.delete(key); + return 0; + } + + String shadowKey = key + ":rebuild"; + redisTemplate.delete(shadowKey); + + Set> tuples = new HashSet<>(rows.size()); + for (ProductScore row : rows) { + tuples.add(ZSetOperations.TypedTuple.of(String.valueOf(row.productId()), row.score())); + } + redisTemplate.opsForZSet().add(shadowKey, tuples); + redisTemplate.rename(shadowKey, key); + redisTemplate.expire(key, TTL); + + return rows.size(); + } + + private record ProductScore(long productId, double score) {} +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java new file mode 100644 index 0000000000..ebc04c2622 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepConfig.java @@ -0,0 +1,77 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.domain.ranking.staging.StagingRankingScored; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class ScoreAggregationStepConfig { + + public static final String STEP_NAME = "scoreAggregationStep"; + private static final int CHUNK_SIZE = 500; + private static final int FETCH_SIZE = 2000; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final StepMonitorListener stepMonitorListener; + private final ScoreProcessor scoreProcessor; + private final StagingScoredWriter stagingScoredWriter; + private final DataSource dataSource; + + @Bean + @StepScope + public JdbcCursorItemReader stagingAggregationCursorReader( + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey + ) { + return new JdbcCursorItemReaderBuilder() + .name("stagingAggregationCursorReader") + .dataSource(dataSource) + .fetchSize(FETCH_SIZE) + // Step 4 는 1:1 row 변환 (streaming aggregation 없음) 이므로 + // saveState=true (기본값) 로 chunk-mid restart 가 정상 작동 + .sql(""" + SELECT period_type, period_key, product_id, + view_count, like_count, sales_amount + FROM staging_ranking_aggregation + WHERE period_key = ? + ORDER BY period_type, product_id + """) + .preparedStatementSetter((ps) -> ps.setString(1, anchorDateKey)) + .rowMapper((rs, rowNum) -> new StagingRankingAggregation( + rs.getString("period_type"), + rs.getString("period_key"), + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_amount") + )) + .build(); + } + + @Bean(STEP_NAME) + public Step scoreAggregationStep(JdbcCursorItemReader stagingAggregationCursorReader) { + return new StepBuilder(STEP_NAME, jobRepository) + .>chunk(CHUNK_SIZE, transactionManager) + .reader(stagingAggregationCursorReader) + .processor(scoreProcessor) + .writer(stagingScoredWriter) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java new file mode 100644 index 0000000000..c77176ba09 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreFormula.java @@ -0,0 +1,25 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.domain.ranking.weight.WeightConfig; + +/** + * 랭킹 스코어 공식 — 순수 함수. + * {@code score = w_view × viewCount + w_like × likeCount + w_order × log10(salesAmount + 1)} + * + *

sales_amount 는 금액 단위라 view/like 카운트에 비해 스케일이 크므로 log10 으로 정규화한다 + * (설계 히스토리: "주문 스코어링에 log10(salesAmount) 정규화 적용" 커밋).

+ * + *

변환 로직은 이 한 곳에 격리되어 있어 가중치·수식 변경 시 이 클래스만 수정하면 된다 + * (설계.md "변경이 자주 있을 부분은 갈아끼울 수 있게" 원칙).

+ */ +public final class ScoreFormula { + + private ScoreFormula() { + } + + public static double compute(long viewCount, long likeCount, long salesAmount, WeightConfig config) { + return config.getWView() * viewCount + + config.getWLike() * likeCount + + config.getWOrder() * Math.log10(salesAmount + 1.0); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java new file mode 100644 index 0000000000..e994d2e1b2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/ScoreProcessor.java @@ -0,0 +1,55 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.domain.ranking.staging.StagingRankingScored; +import com.loopers.domain.ranking.weight.WeightConfig; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * StagingRankingAggregation 1건을 받아 활성 weight_group 수만큼 fan-out 한 StagingRankingScored 를 반환한다. + * + *

weight_group 은 DB 가 아닌 JobExecutionContext 의 스냅샷에서 로드한다. + * Job 최초 시작 시점에 동결된 값이므로 restart 사이에 ranking_weight_config 가 변경되어도 + * fan-out 결과가 흐트러지지 않는다 (Bounded).

+ */ +@Component +@StepScope +public class ScoreProcessor implements ItemProcessor> { + + private List activeConfigs; + + @BeforeStep + public void loadWeightConfigs(StepExecution stepExecution) { + this.activeConfigs = RankingJobParametersListener.restoreWeightConfigs( + stepExecution.getJobExecution().getExecutionContext()); + } + + @Override + public List process(StagingRankingAggregation item) { + List fanOut = new ArrayList<>(activeConfigs.size()); + for (WeightConfig config : activeConfigs) { + double score = ScoreFormula.compute( + item.getViewCount(), item.getLikeCount(), item.getSalesAmount(), config + ); + fanOut.add(new StagingRankingScored( + item.getPeriodType(), + item.getPeriodKey(), + config.getGroupName(), + item.getProductId(), + item.getViewCount(), + item.getLikeCount(), + item.getSalesAmount(), + score + )); + } + return fanOut; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java new file mode 100644 index 0000000000..6bf3de3999 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/score/StagingScoredWriter.java @@ -0,0 +1,69 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.domain.ranking.staging.StagingRankingScored; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 4 Writer — staging_ranking_scored 에 전체 상품 score 적재. + * Step 0 가 해당 anchor 의 scored row 를 비워 놓으므로 여기서는 순수 INSERT. + * 재시작 안전성을 위해 UPSERT 로 기록 (같은 PK 재실행 시 값 갱신). + */ +@Component +@RequiredArgsConstructor +public class StagingScoredWriter implements ItemWriter> { + + private static final String UPSERT_SQL = """ + INSERT INTO staging_ranking_scored + (period_type, period_key, weight_group, product_id, + view_count, like_count, sales_amount, score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count), + like_count = VALUES(like_count), + sales_amount = VALUES(sales_amount), + score = VALUES(score) + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void write(Chunk> chunk) { + List flattened = new ArrayList<>(); + for (List group : chunk) { + flattened.addAll(group); + } + if (flattened.isEmpty()) { + return; + } + + jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + StagingRankingScored row = flattened.get(i); + ps.setString(1, row.getPeriodType()); + ps.setString(2, row.getPeriodKey()); + ps.setString(3, row.getWeightGroup()); + ps.setLong(4, row.getProductId()); + ps.setLong(5, row.getViewCount()); + ps.setLong(6, row.getLikeCount()); + ps.setLong(7, row.getSalesAmount()); + ps.setDouble(8, row.getScore()); + } + + @Override + public int getBatchSize() { + return flattened.size(); + } + }); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java new file mode 100644 index 0000000000..9704fce721 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/AggregatedMetric.java @@ -0,0 +1,8 @@ +package com.loopers.batch.job.ranking.step.stage; + +/** + * App streaming aggregator 가 product_id 경계마다 flush 하는 집계 결과. + * LAST_7D/LAST_30D 범위의 합산이 한 번의 cursor 스캔으로 동시에 계산된다. + */ +public record AggregatedMetric(Long productId, long sum7d, long sum30d) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java new file mode 100644 index 0000000000..f2030cefaa --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/LikeMetricStreamingReader.java @@ -0,0 +1,103 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +/** + * product_like_metrics 를 cursor 로 스트리밍 + App 집계. + * {@link ViewMetricStreamingReader} 와 동일한 패턴 (lookahead 직렬화 포함). + */ +@Component +@StepScope +public class LikeMetricStreamingReader implements ItemStreamReader { + + private static final int FETCH_SIZE = 2000; + private static final String CTX_LOOKAHEAD_PRODUCT_ID = "lookahead.productId"; + private static final String CTX_LOOKAHEAD_BUCKET_TIME = "lookahead.bucketTime"; + private static final String CTX_LOOKAHEAD_COUNT = "lookahead.count"; + + private final JdbcCursorItemReader delegate; + private final LocalDateTime last7dStart; + private StreamingMetricAggregator aggregator; + + public LikeMetricStreamingReader( + DataSource dataSource, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd + ) { + this.last7dStart = LocalDateTime.parse(last7dStart); + LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart); + LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd); + + this.delegate = new JdbcCursorItemReaderBuilder() + .name("likeMetricCursorReader") + .dataSource(dataSource) + .fetchSize(FETCH_SIZE) + .sql(""" + SELECT product_id, bucket_time, like_count + FROM product_like_metrics + WHERE bucket_time >= ? + AND bucket_time < ? + ORDER BY product_id, bucket_time + """) + .preparedStatementSetter((ps) -> { + ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime)); + ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime)); + }) + .rowMapper((rs, rowNum) -> new RawMetricRow( + rs.getLong("product_id"), + rs.getTimestamp("bucket_time").toLocalDateTime(), + rs.getLong("like_count") + )) + .build(); + } + + @Override + public void open(ExecutionContext executionContext) throws ItemStreamException { + delegate.open(executionContext); + this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart); + if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) { + aggregator.setLookahead(new RawMetricRow( + executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID), + LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)), + executionContext.getLong(CTX_LOOKAHEAD_COUNT))); + } + } + + @Override + public void update(ExecutionContext executionContext) throws ItemStreamException { + delegate.update(executionContext); + RawMetricRow lookahead = aggregator.getLookahead(); + if (lookahead != null) { + executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId()); + executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString()); + executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count()); + } else { + executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID); + executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME); + executionContext.remove(CTX_LOOKAHEAD_COUNT); + } + } + + @Override + public void close() throws ItemStreamException { + delegate.close(); + } + + @Override + public AggregatedMetric read() throws Exception { + return aggregator.next(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java new file mode 100644 index 0000000000..094da7c9a7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/OrderMetricStreamingReader.java @@ -0,0 +1,103 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +/** + * product_order_metrics 의 sales_amount 를 cursor 로 스트리밍 + App 집계. + * {@link ViewMetricStreamingReader} 와 동일한 패턴 (lookahead 직렬화 포함). + */ +@Component +@StepScope +public class OrderMetricStreamingReader implements ItemStreamReader { + + private static final int FETCH_SIZE = 2000; + private static final String CTX_LOOKAHEAD_PRODUCT_ID = "lookahead.productId"; + private static final String CTX_LOOKAHEAD_BUCKET_TIME = "lookahead.bucketTime"; + private static final String CTX_LOOKAHEAD_COUNT = "lookahead.count"; + + private final JdbcCursorItemReader delegate; + private final LocalDateTime last7dStart; + private StreamingMetricAggregator aggregator; + + public OrderMetricStreamingReader( + DataSource dataSource, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd + ) { + this.last7dStart = LocalDateTime.parse(last7dStart); + LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart); + LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd); + + this.delegate = new JdbcCursorItemReaderBuilder() + .name("orderMetricCursorReader") + .dataSource(dataSource) + .fetchSize(FETCH_SIZE) + .sql(""" + SELECT product_id, bucket_time, sales_amount + FROM product_order_metrics + WHERE bucket_time >= ? + AND bucket_time < ? + ORDER BY product_id, bucket_time + """) + .preparedStatementSetter((ps) -> { + ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime)); + ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime)); + }) + .rowMapper((rs, rowNum) -> new RawMetricRow( + rs.getLong("product_id"), + rs.getTimestamp("bucket_time").toLocalDateTime(), + rs.getLong("sales_amount") + )) + .build(); + } + + @Override + public void open(ExecutionContext executionContext) throws ItemStreamException { + delegate.open(executionContext); + this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart); + if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) { + aggregator.setLookahead(new RawMetricRow( + executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID), + LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)), + executionContext.getLong(CTX_LOOKAHEAD_COUNT))); + } + } + + @Override + public void update(ExecutionContext executionContext) throws ItemStreamException { + delegate.update(executionContext); + RawMetricRow lookahead = aggregator.getLookahead(); + if (lookahead != null) { + executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId()); + executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString()); + executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count()); + } else { + executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID); + executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME); + executionContext.remove(CTX_LOOKAHEAD_COUNT); + } + } + + @Override + public void close() throws ItemStreamException { + delegate.close(); + } + + @Override + public AggregatedMetric read() throws Exception { + return aggregator.next(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java new file mode 100644 index 0000000000..e21cccc8fd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/RawMetricRow.java @@ -0,0 +1,10 @@ +package com.loopers.batch.job.ranking.step.stage; + +import java.time.LocalDateTime; + +/** + * Cursor 로 한 줄씩 흘러오는 원시 메트릭 row. + * (product_id ASC, bucket_time ASC) 순서로 전달되어야 한다. + */ +public record RawMetricRow(Long productId, LocalDateTime bucketTime, long count) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java new file mode 100644 index 0000000000..6601c7350c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageLikeMetricsStepConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class StageLikeMetricsStepConfig { + + public static final String STEP_NAME = "stageLikeMetricsStep"; + private static final int CHUNK_SIZE = 500; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final StepMonitorListener stepMonitorListener; + private final LikeMetricStreamingReader reader; + private final StagingLikeAggregationProcessor processor; + private final StagingLikeMetricsWriter writer; + + @Bean(STEP_NAME) + public Step stageLikeMetricsStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .>chunk(CHUNK_SIZE, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java new file mode 100644 index 0000000000..8c969ec4b9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageOrderMetricsStepConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class StageOrderMetricsStepConfig { + + public static final String STEP_NAME = "stageOrderMetricsStep"; + private static final int CHUNK_SIZE = 500; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final StepMonitorListener stepMonitorListener; + private final OrderMetricStreamingReader reader; + private final StagingOrderAggregationProcessor processor; + private final StagingOrderMetricsWriter writer; + + @Bean(STEP_NAME) + public Step stageOrderMetricsStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .>chunk(CHUNK_SIZE, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java new file mode 100644 index 0000000000..e5c6bb1cba --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepConfig.java @@ -0,0 +1,39 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class StageViewMetricsStepConfig { + + public static final String STEP_NAME = "stageViewMetricsStep"; + private static final int CHUNK_SIZE = 500; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final StepMonitorListener stepMonitorListener; + private final ViewMetricStreamingReader reader; + private final StagingAggregationProcessor processor; + private final StagingViewMetricsWriter writer; + + @Bean(STEP_NAME) + public Step stageViewMetricsStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .>chunk(CHUNK_SIZE, transactionManager) + .reader(reader) + .processor(processor) + .writer(writer) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java new file mode 100644 index 0000000000..4c7fde2c41 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingAggregationProcessor.java @@ -0,0 +1,44 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * AggregatedMetric(productId, sum7d, sum30d) → {@code List} 2건. + * + *

순수 변환 (I/O 없음, 상태 없음). Chunk 철학의 fan-out 패턴: + * 각 output 이 서로 독립적이어야 하는데 LAST_7D / LAST_30D row 는 독립이므로 OK.

+ */ +@Component +@StepScope +public class StagingAggregationProcessor implements ItemProcessor> { + + public static final String PERIOD_LAST_7D = "LAST_7D"; + public static final String PERIOD_LAST_30D = "LAST_30D"; + + private final String anchorDateKey; + + public StagingAggregationProcessor( + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey + ) { + this.anchorDateKey = anchorDateKey; + } + + @Override + public List process(AggregatedMetric item) { + return List.of( + new StagingRankingAggregation( + PERIOD_LAST_7D, anchorDateKey, item.productId(), + item.sum7d(), 0L, 0L), + new StagingRankingAggregation( + PERIOD_LAST_30D, anchorDateKey, item.productId(), + item.sum30d(), 0L, 0L) + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java new file mode 100644 index 0000000000..1103f594c9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeAggregationProcessor.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Step 2 전용 — AggregatedMetric → like_count 자리에 sum 을 채운 2 건의 fan-out. + */ +@Component +@StepScope +public class StagingLikeAggregationProcessor implements ItemProcessor> { + + private final String anchorDateKey; + + public StagingLikeAggregationProcessor( + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey + ) { + this.anchorDateKey = anchorDateKey; + } + + @Override + public List process(AggregatedMetric item) { + return List.of( + new StagingRankingAggregation( + StagingAggregationProcessor.PERIOD_LAST_7D, anchorDateKey, item.productId(), + 0L, item.sum7d(), 0L), + new StagingRankingAggregation( + StagingAggregationProcessor.PERIOD_LAST_30D, anchorDateKey, item.productId(), + 0L, item.sum30d(), 0L) + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java new file mode 100644 index 0000000000..e3e85870ab --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingLikeMetricsWriter.java @@ -0,0 +1,60 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 2 Writer — staging_ranking_aggregation 의 like_count 만 UPSERT. + * Step 1 이 INSERT 로 row 를 먼저 만든 상태에서 기존 row 의 like_count 컬럼만 갱신한다. + */ +@Component +@RequiredArgsConstructor +public class StagingLikeMetricsWriter implements ItemWriter> { + + private static final String UPSERT_SQL = """ + INSERT INTO staging_ranking_aggregation + (period_type, period_key, product_id, view_count, like_count, sales_amount) + VALUES (?, ?, ?, 0, ?, 0) + ON DUPLICATE KEY UPDATE + like_count = VALUES(like_count) + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void write(Chunk> chunk) { + List flattened = new ArrayList<>(); + for (List group : chunk) { + flattened.addAll(group); + } + if (flattened.isEmpty()) { + return; + } + + jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + StagingRankingAggregation row = flattened.get(i); + ps.setString(1, row.getPeriodType()); + ps.setString(2, row.getPeriodKey()); + ps.setLong(3, row.getProductId()); + ps.setLong(4, row.getLikeCount()); + } + + @Override + public int getBatchSize() { + return flattened.size(); + } + }); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java new file mode 100644 index 0000000000..afa14ebe2f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderAggregationProcessor.java @@ -0,0 +1,38 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Step 3 전용 — AggregatedMetric → sales_amount 자리에 sum 을 채운 2 건의 fan-out. + */ +@Component +@StepScope +public class StagingOrderAggregationProcessor implements ItemProcessor> { + + private final String anchorDateKey; + + public StagingOrderAggregationProcessor( + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") String anchorDateKey + ) { + this.anchorDateKey = anchorDateKey; + } + + @Override + public List process(AggregatedMetric item) { + return List.of( + new StagingRankingAggregation( + StagingAggregationProcessor.PERIOD_LAST_7D, anchorDateKey, item.productId(), + 0L, 0L, item.sum7d()), + new StagingRankingAggregation( + StagingAggregationProcessor.PERIOD_LAST_30D, anchorDateKey, item.productId(), + 0L, 0L, item.sum30d()) + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java new file mode 100644 index 0000000000..92930c40ff --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingOrderMetricsWriter.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 3 Writer — staging_ranking_aggregation 의 sales_amount 만 UPSERT. + */ +@Component +@RequiredArgsConstructor +public class StagingOrderMetricsWriter implements ItemWriter> { + + private static final String UPSERT_SQL = """ + INSERT INTO staging_ranking_aggregation + (period_type, period_key, product_id, view_count, like_count, sales_amount) + VALUES (?, ?, ?, 0, 0, ?) + ON DUPLICATE KEY UPDATE + sales_amount = VALUES(sales_amount) + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void write(Chunk> chunk) { + List flattened = new ArrayList<>(); + for (List group : chunk) { + flattened.addAll(group); + } + if (flattened.isEmpty()) { + return; + } + + jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + StagingRankingAggregation row = flattened.get(i); + ps.setString(1, row.getPeriodType()); + ps.setString(2, row.getPeriodKey()); + ps.setLong(3, row.getProductId()); + ps.setLong(4, row.getSalesAmount()); + } + + @Override + public int getBatchSize() { + return flattened.size(); + } + }); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java new file mode 100644 index 0000000000..c4d0eb9cec --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StagingViewMetricsWriter.java @@ -0,0 +1,65 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 1 Writer — staging_ranking_aggregation 에 view_count 만 UPSERT. + * + *

Step 2 (like), Step 3 (order) 는 각각 like_count / sales_amount 만 UPDATE 하므로, + * Step 1 의 INSERT 이후 해당 row 의 다른 컬럼은 0 으로 남아 있다가 Step 2/3 에서 채워진다.

+ * + *

JPA {@code saveAll()} 대신 JDBC batch UPSERT 를 쓰는 이유는 설계.md 의 + * "JPA Writer 함정" 섹션 참고: merge() 건별 SELECT 회피 + 1차 캐시 OOM 방지.

+ */ +@Component +@RequiredArgsConstructor +public class StagingViewMetricsWriter implements ItemWriter> { + + private static final String UPSERT_SQL = """ + INSERT INTO staging_ranking_aggregation + (period_type, period_key, product_id, view_count, like_count, sales_amount) + VALUES (?, ?, ?, ?, 0, 0) + ON DUPLICATE KEY UPDATE + view_count = VALUES(view_count) + """; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void write(Chunk> chunk) { + List flattened = new ArrayList<>(); + for (List group : chunk) { + flattened.addAll(group); + } + if (flattened.isEmpty()) { + return; + } + + jdbcTemplate.batchUpdate(UPSERT_SQL, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + StagingRankingAggregation row = flattened.get(i); + ps.setString(1, row.getPeriodType()); + ps.setString(2, row.getPeriodKey()); + ps.setLong(3, row.getProductId()); + ps.setLong(4, row.getViewCount()); + } + + @Override + public int getBatchSize() { + return flattened.size(); + } + }); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java new file mode 100644 index 0000000000..93606ded1b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregator.java @@ -0,0 +1,77 @@ +package com.loopers.batch.job.ranking.step.stage; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * product_id 순서로 정렬된 raw row 들을 소비하며 product 경계마다 집계 결과를 내놓는다. + * + *

DB 가 GROUP BY 를 수행하지 않고 App 에서 스트리밍 집계하는 이유는 프롤로그 + * "배치의 본질 — 예측 가능성 > 평균" 원칙에서 도출된다. + * 입력이 N배 튀어도 본 로직은 정확히 N배 시간만 선형으로 늘어난다.

+ * + *

메모리는 한 상품의 누적 값 두 개(sum7d, sum30d) 만 유지한다 → O(1).

+ * + *

전제: source 는 {@code null} 로 끝점을 알리며, row 는 product_id 로 이미 정렬되어 있다.

+ */ +public final class StreamingMetricAggregator { + + public interface RowSource { + RawMetricRow readOne() throws Exception; + } + + private final RowSource source; + private final LocalDateTime last7dStart; + private RawMetricRow lookahead; + private boolean exhausted; + + public StreamingMetricAggregator(RowSource source, LocalDateTime last7dStart) { + this.source = Objects.requireNonNull(source); + this.last7dStart = Objects.requireNonNull(last7dStart); + } + + /** + * 다음 product 의 집계 결과를 반환하거나, 더 이상 없으면 {@code null}. + */ + public AggregatedMetric next() throws Exception { + if (exhausted) { + return null; + } + + RawMetricRow current = (lookahead != null) ? lookahead : source.readOne(); + lookahead = null; + if (current == null) { + exhausted = true; + return null; + } + + Long productId = current.productId(); + long sum7d = 0L; + long sum30d = 0L; + + while (current != null && productId.equals(current.productId())) { + sum30d += current.count(); + if (!current.bucketTime().isBefore(last7dStart)) { + sum7d += current.count(); + } + current = source.readOne(); + } + + // 다음 product 의 첫 row 를 lookahead 에 보관 (다음 next() 호출에서 사용) + lookahead = current; + if (current == null) { + exhausted = true; + } + return new AggregatedMetric(productId, sum7d, sum30d); + } + + /** restart 시 ExecutionContext 직렬화/복원을 위한 lookahead 접근자. */ + public RawMetricRow getLookahead() { + return lookahead; + } + + /** restart 시 ExecutionContext 에서 복원한 lookahead 를 주입한다. */ + public void setLookahead(RawMetricRow lookahead) { + this.lookahead = lookahead; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java new file mode 100644 index 0000000000..7c66f3057e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/stage/ViewMetricStreamingReader.java @@ -0,0 +1,118 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamException; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +/** + * product_view_metrics 를 (product_id, bucket_time) 순 cursor 로 스트리밍 읽고, + * App 측 StreamingMetricAggregator 로 product 경계마다 AggregatedMetric 을 흘려보낸다. + * + *

DB 는 단순 range scan 만 수행한다 (GROUP BY 없음). 집계는 App 책임.

+ * + *

streaming aggregator 의 lookahead (다음 product 의 첫 row) 를 + * ExecutionContext 에 직렬화하여 chunk-mid restart 시에도 누락 없이 이어갈 수 있다. + * 이는 Spring Batch 의 ItemStream 정석 패턴이다 (Common Batch Patterns 참고).

+ */ +@Slf4j +@Component +@StepScope +public class ViewMetricStreamingReader implements ItemStreamReader { + + private static final int FETCH_SIZE = 2000; + + private static final String CTX_LOOKAHEAD_PRODUCT_ID = "lookahead.productId"; + private static final String CTX_LOOKAHEAD_BUCKET_TIME = "lookahead.bucketTime"; + private static final String CTX_LOOKAHEAD_COUNT = "lookahead.count"; + + private final JdbcCursorItemReader delegate; + private final LocalDateTime last7dStart; + private StreamingMetricAggregator aggregator; + + public ViewMetricStreamingReader( + DataSource dataSource, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_7D_START + "']}") String last7dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_START + "']}") String last30dStart, + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_LAST_30D_END + "']}") String last30dEnd + ) { + this.last7dStart = LocalDateTime.parse(last7dStart); + LocalDateTime last30dStartTime = LocalDateTime.parse(last30dStart); + LocalDateTime last30dEndTime = LocalDateTime.parse(last30dEnd); + + this.delegate = new JdbcCursorItemReaderBuilder() + .name("viewMetricCursorReader") + .dataSource(dataSource) + .fetchSize(FETCH_SIZE) + .sql(""" + SELECT product_id, bucket_time, view_count + FROM product_view_metrics + WHERE bucket_time >= ? + AND bucket_time < ? + ORDER BY product_id, bucket_time + """) + .preparedStatementSetter((ps) -> { + ps.setTimestamp(1, Timestamp.valueOf(last30dStartTime)); + ps.setTimestamp(2, Timestamp.valueOf(last30dEndTime)); + }) + .rowMapper((rs, rowNum) -> new RawMetricRow( + rs.getLong("product_id"), + rs.getTimestamp("bucket_time").toLocalDateTime(), + rs.getLong("view_count") + )) + .build(); + } + + @Override + public void open(ExecutionContext executionContext) throws ItemStreamException { + delegate.open(executionContext); + this.aggregator = new StreamingMetricAggregator(delegate::read, last7dStart); + + // restart 시 이전 chunk 종료 시점의 lookahead 복원 + if (executionContext.containsKey(CTX_LOOKAHEAD_PRODUCT_ID)) { + RawMetricRow restored = new RawMetricRow( + executionContext.getLong(CTX_LOOKAHEAD_PRODUCT_ID), + LocalDateTime.parse(executionContext.getString(CTX_LOOKAHEAD_BUCKET_TIME)), + executionContext.getLong(CTX_LOOKAHEAD_COUNT) + ); + aggregator.setLookahead(restored); + } + } + + @Override + public void update(ExecutionContext executionContext) throws ItemStreamException { + delegate.update(executionContext); + + // chunk commit 시점에 lookahead 를 primitive 3개로 직렬화 + RawMetricRow lookahead = aggregator.getLookahead(); + if (lookahead != null) { + executionContext.putLong(CTX_LOOKAHEAD_PRODUCT_ID, lookahead.productId()); + executionContext.putString(CTX_LOOKAHEAD_BUCKET_TIME, lookahead.bucketTime().toString()); + executionContext.putLong(CTX_LOOKAHEAD_COUNT, lookahead.count()); + } else { + executionContext.remove(CTX_LOOKAHEAD_PRODUCT_ID); + executionContext.remove(CTX_LOOKAHEAD_BUCKET_TIME); + executionContext.remove(CTX_LOOKAHEAD_COUNT); + } + } + + @Override + public void close() throws ItemStreamException { + delegate.close(); + } + + @Override + public AggregatedMetric read() throws Exception { + return aggregator.next(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java new file mode 100644 index 0000000000..56652bf809 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingTasklet.java @@ -0,0 +1,47 @@ +package com.loopers.batch.job.ranking.step.truncate; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import com.loopers.domain.ranking.staging.StagingRankingScoredRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Step 0 — 현재 anchor 의 스테이징만 초기화한다 (전체 TRUNCATE 아님). + * 재실행 시 이전 시도의 잔재를 제거하여 멱등성 확보. + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class TruncateStagingTasklet implements Tasklet { + + private final StagingRankingAggregationRepository aggregationRepository; + private final StagingRankingScoredRepository scoredRepository; + + @Value("#{jobExecutionContext['" + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY + "']}") + private String anchorDateKey; + + @Override + @Transactional + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + int aggregationDeleted = aggregationRepository.deleteByPeriodKey(anchorDateKey); + int scoredDeleted = scoredRepository.deleteByPeriodKey(anchorDateKey); + + log.info( + "[STEP=truncateStagingStep] anchorDateKey={} aggregationDeleted={} scoredDeleted={}", + anchorDateKey, aggregationDeleted, scoredDeleted + ); + + contribution.incrementWriteCount(aggregationDeleted + scoredDeleted); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java new file mode 100644 index 0000000000..457856560d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductLikeMetric.java @@ -0,0 +1,44 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델. + * ProductViewMetric 의 주석 참고. + */ +@Entity +@Table(name = "product_like_metrics", indexes = { + @Index(name = "idx_plm_bucket_time", columnList = "bucket_time") +}) +@IdClass(ProductMetricId.class) +@Getter +public class ProductLikeMetric { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "bucket_time") + private LocalDateTime bucketTime; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + protected ProductLikeMetric() { + } + + public ProductLikeMetric(Long productId, LocalDateTime bucketTime, long likeCount) { + this.productId = productId; + this.bucketTime = bucketTime; + this.likeCount = likeCount; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java new file mode 100644 index 0000000000..dd91c9c273 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricId.java @@ -0,0 +1,32 @@ +package com.loopers.domain.metrics; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Objects; + +public class ProductMetricId implements Serializable { + + private Long productId; + private LocalDateTime bucketTime; + + public ProductMetricId() { + } + + public ProductMetricId(Long productId, LocalDateTime bucketTime) { + this.productId = productId; + this.bucketTime = bucketTime; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ProductMetricId that)) return false; + return Objects.equals(productId, that.productId) + && Objects.equals(bucketTime, that.bucketTime); + } + + @Override + public int hashCode() { + return Objects.hash(productId, bucketTime); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java new file mode 100644 index 0000000000..3d4c591a42 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductOrderMetric.java @@ -0,0 +1,53 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델. + * ProductViewMetric 의 주석 참고. + */ +@Entity +@Table(name = "product_order_metrics", indexes = { + @Index(name = "idx_pom_bucket_time", columnList = "bucket_time") +}) +@IdClass(ProductMetricId.class) +@Getter +public class ProductOrderMetric { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "bucket_time") + private LocalDateTime bucketTime; + + @Column(name = "order_count", nullable = false) + private int orderCount; + + @Column(name = "quantity", nullable = false) + private long quantity; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + protected ProductOrderMetric() { + } + + public ProductOrderMetric(Long productId, LocalDateTime bucketTime, + int orderCount, long quantity, long salesAmount) { + this.productId = productId; + this.bucketTime = bucketTime; + this.orderCount = orderCount; + this.quantity = quantity; + this.salesAmount = salesAmount; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java new file mode 100644 index 0000000000..1c34819318 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductViewMetric.java @@ -0,0 +1,48 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * commerce-streamer 가 관리하는 원천 시계열 테이블의 배치 측 읽기 모델. + * + * 이 엔티티는 배치가 테이블을 "읽기 위한 schema 정의" 로만 존재한다: + * - 프로덕션에서는 streamer 가 이 테이블을 소유하고 UPSERT 한다 + * - 배치는 JdbcCursorItemReader 로 원시 row 만 스트리밍하여 App 에서 집계한다 + * - 테스트 환경에서 ddl-auto 가 동일 스키마를 생성할 수 있도록 미러 엔티티를 둔다 + */ +@Entity +@Table(name = "product_view_metrics", indexes = { + @Index(name = "idx_pvm_bucket_time", columnList = "bucket_time") +}) +@IdClass(ProductMetricId.class) +@Getter +public class ProductViewMetric { + + @Id + @Column(name = "product_id") + private Long productId; + + @Id + @Column(name = "bucket_time") + private LocalDateTime bucketTime; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + protected ProductViewMetric() { + } + + public ProductViewMetric(Long productId, LocalDateTime bucketTime, long viewCount) { + this.productId = productId; + this.bucketTime = bucketTime; + this.viewCount = viewCount; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java new file mode 100644 index 0000000000..6c972dfa33 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLog.java @@ -0,0 +1,78 @@ +package com.loopers.domain.ranking.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * MV 적재 실행 이력. + * "이 anchor, 이 period, 이 weight_group 에 N건 적재 완료" 를 기록한다. + * Step 5 (promote) 에서 MV INSERT 와 같은 TX 안에서 커밋된다. + */ +@Entity +@Table( + name = "batch_audit_log", + indexes = @Index( + name = "idx_audit_anchor", + columnList = "anchor_date, period_type, weight_group" + ) +) +@Getter +public class BatchAuditLog { + + public static final String STATUS_OK = "OK"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "job_execution_id", nullable = false) + private Long jobExecutionId; + + @Column(name = "anchor_date", nullable = false) + private LocalDate anchorDate; + + @Column(name = "period_type", length = 16, nullable = false) + private String periodType; + + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Column(name = "status", length = 8, nullable = false) + private String status; + + @Column(name = "row_count", nullable = false) + private int rowCount; + + @Column(name = "reason", length = 255) + private String reason; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected BatchAuditLog() { + } + + public static BatchAuditLog ok(Long jobExecutionId, LocalDate anchorDate, + String periodType, String weightGroup, int rowCount) { + BatchAuditLog log = new BatchAuditLog(); + log.jobExecutionId = jobExecutionId; + log.anchorDate = anchorDate; + log.periodType = periodType; + log.weightGroup = weightGroup; + log.status = STATUS_OK; + log.rowCount = rowCount; + log.reason = null; + log.createdAt = LocalDateTime.now(); + return log; + } + +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java new file mode 100644 index 0000000000..fcb0e1ab0c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/audit/BatchAuditLogRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking.audit; + +import java.time.LocalDate; +import java.util.List; + +public interface BatchAuditLogRepository { + + // Command + BatchAuditLog save(BatchAuditLog log); + + // Query + List findByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java new file mode 100644 index 0000000000..f4d2b11cd4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankId.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking.mv; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +/** + * mv_product_rank_last_7d / mv_product_rank_last_30d 공통 PK 클래스. + * 자연 키: (anchor_date, weight_group, product_id) + */ +public class MvProductRankId implements Serializable { + + private LocalDate anchorDate; + private String weightGroup; + private Long productId; + + public MvProductRankId() { + } + + public MvProductRankId(LocalDate anchorDate, String weightGroup, Long productId) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MvProductRankId that)) return false; + return Objects.equals(anchorDate, that.anchorDate) + && Objects.equals(weightGroup, that.weightGroup) + && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(anchorDate, weightGroup, productId); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java new file mode 100644 index 0000000000..6b85008e5a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30d.java @@ -0,0 +1,75 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 롤링 30일 랭킹 확정 MV. {@link MvProductRankLast7d} 주석 참고. + */ +@Entity +@Table( + name = "mv_product_rank_last_30d", + indexes = @Index( + name = "idx_last_30d_rank", + columnList = "anchor_date, weight_group, rank_position" + ) +) +@IdClass(MvProductRankId.class) +@Getter +public class MvProductRankLast30d { + + @Id + @Column(name = "anchor_date", nullable = false) + private LocalDate anchorDate; + + @Id + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected MvProductRankLast30d() { + } + + public MvProductRankLast30d(LocalDate anchorDate, String weightGroup, Long productId, + long viewCount, long likeCount, long salesAmount, + double score, int rankPosition, LocalDateTime createdAt) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + this.score = score; + this.rankPosition = rankPosition; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java new file mode 100644 index 0000000000..1a62676bd3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast30dRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.ranking.mv; + +import java.time.LocalDate; + +public interface MvProductRankLast30dRepository { + + // Command + MvProductRankLast30d save(MvProductRankLast30d entity); + + int deleteByAnchorDate(LocalDate anchorDate); + + // Query + long countByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java new file mode 100644 index 0000000000..86d8cf0019 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7d.java @@ -0,0 +1,81 @@ +package com.loopers.domain.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 롤링 7일 랭킹 확정 MV — TOP 100 만 저장되는 조회 전용 영속화 테이블. + * + *

배치 도중에는 MV 에 "비어있음" 또는 "확정된 TOP 100" 두 상태만 존재하도록 설계됐다 + * (중간 상태 불가시성, 설계.md 프롤로그 "확정됨(committed)" 원칙).

+ * + *

Step 4a 가 해당 anchor_date 의 row 를 사전 DELETE 하고, + * Step 5b 가 Top 100 을 단일 SQL INSERT 로 채운다.

+ */ +@Entity +@Table( + name = "mv_product_rank_last_7d", + indexes = @Index( + name = "idx_last_7d_rank", + columnList = "anchor_date, weight_group, rank_position" + ) +) +@IdClass(MvProductRankId.class) +@Getter +public class MvProductRankLast7d { + + @Id + @Column(name = "anchor_date", nullable = false) + private LocalDate anchorDate; + + @Id + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "score", nullable = false) + private double score; + + @Column(name = "rank_position", nullable = false) + private int rankPosition; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected MvProductRankLast7d() { + } + + public MvProductRankLast7d(LocalDate anchorDate, String weightGroup, Long productId, + long viewCount, long likeCount, long salesAmount, + double score, int rankPosition, LocalDateTime createdAt) { + this.anchorDate = anchorDate; + this.weightGroup = weightGroup; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + this.score = score; + this.rankPosition = rankPosition; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java new file mode 100644 index 0000000000..047923e956 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/MvProductRankLast7dRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.ranking.mv; + +import java.time.LocalDate; + +public interface MvProductRankLast7dRepository { + + // Command + MvProductRankLast7d save(MvProductRankLast7d entity); + + int deleteByAnchorDate(LocalDate anchorDate); + + // Query + long countByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java new file mode 100644 index 0000000000..c2c6f7f83e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregation.java @@ -0,0 +1,53 @@ +package com.loopers.domain.ranking.staging; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; + +/** + * 1차 스테이징 — 각 메트릭(view/like/order) 을 product 단위로 합산한 raw sum 보관소. + * Step 1~3 가 UPSERT 로 채우고 Step 5 가 읽어 간다. + */ +@Entity +@Table(name = "staging_ranking_aggregation") +@IdClass(StagingRankingAggregationId.class) +@Getter +public class StagingRankingAggregation { + + @Id + @Column(name = "period_type", length = 16, nullable = false) + private String periodType; + + @Id + @Column(name = "period_key", length = 8, nullable = false) + private String periodKey; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + protected StagingRankingAggregation() { + } + + public StagingRankingAggregation(String periodType, String periodKey, Long productId, + long viewCount, long likeCount, long salesAmount) { + this.periodType = periodType; + this.periodKey = periodKey; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java new file mode 100644 index 0000000000..397c1c21ea --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationId.java @@ -0,0 +1,34 @@ +package com.loopers.domain.ranking.staging; + +import java.io.Serializable; +import java.util.Objects; + +public class StagingRankingAggregationId implements Serializable { + + private String periodType; + private String periodKey; + private Long productId; + + public StagingRankingAggregationId() { + } + + public StagingRankingAggregationId(String periodType, String periodKey, Long productId) { + this.periodType = periodType; + this.periodKey = periodKey; + this.productId = productId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof StagingRankingAggregationId that)) return false; + return Objects.equals(periodType, that.periodType) + && Objects.equals(periodKey, that.periodKey) + && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(periodType, periodKey, productId); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java new file mode 100644 index 0000000000..76bd920740 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingAggregationRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking.staging; + +public interface StagingRankingAggregationRepository { + + // Command + StagingRankingAggregation save(StagingRankingAggregation entity); + + int deleteByPeriodKey(String periodKey); + + // Query + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java new file mode 100644 index 0000000000..fb2a6fbb07 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScored.java @@ -0,0 +1,70 @@ +package com.loopers.domain.ranking.staging; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +/** + * 2차 스테이징 — 1차 스테이징을 score 까지 계산해둔 전체 상품 격리 공간. + * MV 에 TOP 100 만 진입시키기 위해, "전체 상품 score" 라는 중간 상태를 MV 가 아니라 여기에 둔다. + * Step 5(Chunk) 가 채우고 Step 5b(Tasklet) 가 TOP 100 으로 MV 에 promote 한다. + */ +@Entity +@Table( + name = "staging_ranking_scored", + indexes = @Index( + name = "idx_scored_sort", + columnList = "period_type, period_key, weight_group, score" + ) +) +@IdClass(StagingRankingScoredId.class) +@Getter +public class StagingRankingScored { + + @Id + @Column(name = "period_type", length = 16, nullable = false) + private String periodType; + + @Id + @Column(name = "period_key", length = 8, nullable = false) + private String periodKey; + + @Id + @Column(name = "weight_group", length = 32, nullable = false) + private String weightGroup; + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "sales_amount", nullable = false) + private long salesAmount; + + @Column(name = "score", nullable = false) + private double score; + + protected StagingRankingScored() { + } + + public StagingRankingScored(String periodType, String periodKey, String weightGroup, Long productId, + long viewCount, long likeCount, long salesAmount, double score) { + this.periodType = periodType; + this.periodKey = periodKey; + this.weightGroup = weightGroup; + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesAmount = salesAmount; + this.score = score; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java new file mode 100644 index 0000000000..a7c4c3744b --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredId.java @@ -0,0 +1,37 @@ +package com.loopers.domain.ranking.staging; + +import java.io.Serializable; +import java.util.Objects; + +public class StagingRankingScoredId implements Serializable { + + private String periodType; + private String periodKey; + private String weightGroup; + private Long productId; + + public StagingRankingScoredId() { + } + + public StagingRankingScoredId(String periodType, String periodKey, String weightGroup, Long productId) { + this.periodType = periodType; + this.periodKey = periodKey; + this.weightGroup = weightGroup; + this.productId = productId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof StagingRankingScoredId that)) return false; + return Objects.equals(periodType, that.periodType) + && Objects.equals(periodKey, that.periodKey) + && Objects.equals(weightGroup, that.weightGroup) + && Objects.equals(productId, that.productId); + } + + @Override + public int hashCode() { + return Objects.hash(periodType, periodKey, weightGroup, productId); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java new file mode 100644 index 0000000000..ad4e61b860 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/staging/StagingRankingScoredRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking.staging; + +public interface StagingRankingScoredRepository { + + // Command + StagingRankingScored save(StagingRankingScored entity); + + int deleteByPeriodKey(String periodKey); + + // Query + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java new file mode 100644 index 0000000000..95604440fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfig.java @@ -0,0 +1,64 @@ +package com.loopers.domain.ranking.weight; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; + +/** + * commerce-api / commerce-streamer 가 공유하는 랭킹 가중치 설정의 배치 측 읽기 모델. + * 배치는 @BeforeStep 에서 active=true 인 행들만 로드하여 Processor 에서 그룹별 fan-out 에 사용한다. + */ +@Entity +@Table(name = "ranking_weight_config") +@Getter +public class WeightConfig { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_name", nullable = false, unique = true, length = 50) + private String groupName; + + @Column(name = "w_view", nullable = false) + private double wView; + + @Column(name = "w_like", nullable = false) + private double wLike; + + @Column(name = "w_order", nullable = false) + private double wOrder; + + @Column(name = "traffic_pct", nullable = false) + private int trafficPct; + + @Column(name = "active", nullable = false) + private boolean active; + + @Column(name = "created_at", nullable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected WeightConfig() { + } + + public WeightConfig(String groupName, double wView, double wLike, double wOrder, + int trafficPct, boolean active) { + this.groupName = groupName; + this.wView = wView; + this.wLike = wLike; + this.wOrder = wOrder; + this.trafficPct = trafficPct; + this.active = active; + this.createdAt = ZonedDateTime.now(); + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java new file mode 100644 index 0000000000..1041c27136 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/weight/WeightConfigRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.ranking.weight; + +import java.util.List; + +public interface WeightConfigRepository { + + // Command (test fixture 용) + WeightConfig save(WeightConfig entity); + + // Query + List findAllByActiveTrue(); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java new file mode 100644 index 0000000000..4d804a26d5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.audit.BatchAuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; + +interface BatchAuditLogJpaRepository extends JpaRepository { + + List findByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java new file mode 100644 index 0000000000..96e733bceb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/BatchAuditLogRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.audit.BatchAuditLog; +import com.loopers.domain.ranking.audit.BatchAuditLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class BatchAuditLogRepositoryImpl implements BatchAuditLogRepository { + + private final BatchAuditLogJpaRepository jpaRepository; + + @Override + public BatchAuditLog save(BatchAuditLog log) { + return jpaRepository.save(log); + } + + @Override + public List findByAnchorDate(LocalDate anchorDate) { + return jpaRepository.findByAnchorDate(anchorDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java new file mode 100644 index 0000000000..7d37faae23 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.mv.MvProductRankId; +import com.loopers.domain.ranking.mv.MvProductRankLast30d; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +interface MvProductRankLast30dJpaRepository + extends JpaRepository { + + // Command + @Modifying + @Query("DELETE FROM MvProductRankLast30d m WHERE m.anchorDate = :anchorDate") + int deleteByAnchorDate(@Param("anchorDate") LocalDate anchorDate); + + // Query + long countByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java new file mode 100644 index 0000000000..9c39c491b4 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast30dRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.mv.MvProductRankLast30d; +import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; + +@Repository +@RequiredArgsConstructor +public class MvProductRankLast30dRepositoryImpl implements MvProductRankLast30dRepository { + + private final MvProductRankLast30dJpaRepository jpaRepository; + + @Override + public MvProductRankLast30d save(MvProductRankLast30d entity) { + return jpaRepository.save(entity); + } + + @Override + public int deleteByAnchorDate(LocalDate anchorDate) { + return jpaRepository.deleteByAnchorDate(anchorDate); + } + + @Override + public long countByAnchorDate(LocalDate anchorDate) { + return jpaRepository.countByAnchorDate(anchorDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java new file mode 100644 index 0000000000..f79de26439 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.mv.MvProductRankId; +import com.loopers.domain.ranking.mv.MvProductRankLast7d; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; + +interface MvProductRankLast7dJpaRepository + extends JpaRepository { + + // Command + @Modifying + @Query("DELETE FROM MvProductRankLast7d m WHERE m.anchorDate = :anchorDate") + int deleteByAnchorDate(@Param("anchorDate") LocalDate anchorDate); + + // Query + long countByAnchorDate(LocalDate anchorDate); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java new file mode 100644 index 0000000000..6564ad2107 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankLast7dRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.mv.MvProductRankLast7d; +import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; + +@Repository +@RequiredArgsConstructor +public class MvProductRankLast7dRepositoryImpl implements MvProductRankLast7dRepository { + + private final MvProductRankLast7dJpaRepository jpaRepository; + + @Override + public MvProductRankLast7d save(MvProductRankLast7d entity) { + return jpaRepository.save(entity); + } + + @Override + public int deleteByAnchorDate(LocalDate anchorDate) { + return jpaRepository.deleteByAnchorDate(anchorDate); + } + + @Override + public long countByAnchorDate(LocalDate anchorDate) { + return jpaRepository.countByAnchorDate(anchorDate); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java new file mode 100644 index 0000000000..0cddd8538f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.domain.ranking.staging.StagingRankingAggregationId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface StagingRankingAggregationJpaRepository + extends JpaRepository { + + // Command + @Modifying + @Query("DELETE FROM StagingRankingAggregation s WHERE s.periodKey = :periodKey") + int deleteByPeriodKey(@Param("periodKey") String periodKey); + + // Query + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java new file mode 100644 index 0000000000..1248a8fbcb --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingAggregationRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StagingRankingAggregationRepositoryImpl implements StagingRankingAggregationRepository { + + private final StagingRankingAggregationJpaRepository jpaRepository; + + @Override + public StagingRankingAggregation save(StagingRankingAggregation entity) { + return jpaRepository.save(entity); + } + + @Override + public int deleteByPeriodKey(String periodKey) { + return jpaRepository.deleteByPeriodKey(periodKey); + } + + @Override + public long countByPeriodKey(String periodKey) { + return jpaRepository.countByPeriodKey(periodKey); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java new file mode 100644 index 0000000000..1219c17167 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.staging.StagingRankingScored; +import com.loopers.domain.ranking.staging.StagingRankingScoredId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface StagingRankingScoredJpaRepository + extends JpaRepository { + + // Command + @Modifying + @Query("DELETE FROM StagingRankingScored s WHERE s.periodKey = :periodKey") + int deleteByPeriodKey(@Param("periodKey") String periodKey); + + // Query + long countByPeriodKey(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java new file mode 100644 index 0000000000..69cdfb86fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/StagingRankingScoredRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.staging.StagingRankingScored; +import com.loopers.domain.ranking.staging.StagingRankingScoredRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StagingRankingScoredRepositoryImpl implements StagingRankingScoredRepository { + + private final StagingRankingScoredJpaRepository jpaRepository; + + @Override + public StagingRankingScored save(StagingRankingScored entity) { + return jpaRepository.save(entity); + } + + @Override + public int deleteByPeriodKey(String periodKey) { + return jpaRepository.deleteByPeriodKey(periodKey); + } + + @Override + public long countByPeriodKey(String periodKey) { + return jpaRepository.countByPeriodKey(periodKey); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java new file mode 100644 index 0000000000..e9166b0ab3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.weight.WeightConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +interface WeightConfigJpaRepository extends JpaRepository { + + List findAllByActiveTrue(); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java new file mode 100644 index 0000000000..bf807be75d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeightConfigRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class WeightConfigRepositoryImpl implements WeightConfigRepository { + + private final WeightConfigJpaRepository jpaRepository; + + @Override + public WeightConfig save(WeightConfig entity) { + return jpaRepository.save(entity); + } + + @Override + public List findAllByActiveTrue() { + return jpaRepository.findAllByActiveTrue(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java new file mode 100644 index 0000000000..38a55b0c4e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobE2ETest.java @@ -0,0 +1,222 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.audit.BatchAuditLog; +import com.loopers.domain.ranking.audit.BatchAuditLogRepository; +import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository; +import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository; +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * 랭킹 배치 전체 파이프라인 E2E — Step 0 ~ Step 6 까지의 통과 검증. + * 원천 3개 테이블에 시드 → Job 실행 → MV + audit_log + Redis ZSET 결과 검증. + */ +@SpringBootTest +@SpringBatchTest +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RollingRankingJobE2ETest { + + private static final String ANCHOR_KEY = "20260414"; + private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14); + private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0); + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private WeightConfigRepository weightConfigRepository; + @Autowired private MvProductRankLast7dRepository last7dRepository; + @Autowired private MvProductRankLast30dRepository last30dRepository; + @Autowired private BatchAuditLogRepository auditLogRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private RedisTemplate redisTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + class 전체_파이프라인 { + + @Test + void 원천에서_MV_audit_Redis_ZSET_까지_전체_파이프라인이_성공하고_identity_cache_가_된다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + + // 3 product, 각각 다른 원천 + saveView(1L, IN_7D, 100); saveLike(1L, IN_7D, 50); saveOrder(1L, IN_7D, 999); + saveView(2L, IN_7D, 50); saveLike(2L, IN_7D, 10); saveOrder(2L, IN_7D, 100); + saveView(3L, IN_7D, 10); saveLike(3L, IN_7D, 2); saveOrder(3L, IN_7D, 10); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY)); + + // MV 에 TOP N (여기선 3 상품) 적재 + rank 1,2,3 연속 + // Redis ZSET 에 같은 score 순으로 적재 + Set> zsetLast7d = redisTemplate.opsForZSet() + .reverseRangeWithScores("ranking:last7d:" + ANCHOR_KEY + ":control", 0, -1); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L), + () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isEqualTo(3L), + () -> assertThat(rankPositions("mv_product_rank_last_7d", "control")).containsExactly(1, 2, 3), + // audit 로그 2건 (LAST_7D + LAST_30D) + () -> assertThat(auditLogRepository.findByAnchorDate(ANCHOR)) + .extracting(BatchAuditLog::getStatus) + .containsOnly(BatchAuditLog.STATUS_OK), + // Redis ZSET 에 동일 3 상품이 동일 score 로 들어감 (identity cache) + () -> assertThat(zsetLast7d).hasSize(3) + ); + } + + @Test + void 여러_weight_group_이_활성화되면_MV_Redis_모두_그룹별로_독립_생성된다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true)); + weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true)); + + saveView(1L, IN_7D, 100); + saveLike(1L, IN_7D, 50); + saveOrder(1L, IN_7D, 500); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // control + experiment_a 두 그룹 × 1 상품 = 2 row per MV + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(2L), + () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isTrue(), + () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":experiment_a")).isTrue(), + () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":control")).isTrue(), + () -> assertThat(redisTemplate.hasKey("ranking:last30d:" + ANCHOR_KEY + ":experiment_a")).isTrue() + ); + } + } + + @Nested + class 멱등성 { + + @Test + void 같은_anchorDate_로_두번_돌려도_최종_결과가_동일하다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + saveView(1L, IN_7D, 100); + saveLike(1L, IN_7D, 50); + saveOrder(1L, IN_7D, 999); + + jobLauncherTestUtils.setJob(job); + jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY)); + double firstScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L); + + JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY)); + double secondScore = scoreOfMv("mv_product_rank_last_7d", ANCHOR_KEY, "control", 1L); + + assertAll( + () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(1L), + () -> assertThat(secondScore).isEqualTo(firstScore) + ); + } + } + + @Nested + class 빈_원천 { + + @Test + void 원천이_비어_있어도_Job_은_성공한다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR_KEY)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(), + () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(), + () -> assertThat(redisTemplate.hasKey("ranking:last7d:" + ANCHOR_KEY + ":control")).isFalse() + ); + } + } + + // -- helpers -- + + private void saveView(long productId, LocalDateTime bucketTime, long viewCount) { + jdbcTemplate.update( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), viewCount); + } + + private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) { + jdbcTemplate.update( + "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), likeCount); + } + + private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " + + "VALUES (?, ?, 1, 1, ?)", + productId, Timestamp.valueOf(bucketTime), salesAmount); + } + + private List rankPositions(String mvTable, String group) { + return jdbcTemplate.queryForList( + "SELECT rank_position FROM " + mvTable + + " WHERE anchor_date = ? AND weight_group = ? ORDER BY rank_position", + Integer.class, java.sql.Date.valueOf(ANCHOR), group); + } + + private double scoreOfMv(String mvTable, String anchorKey, String group, long productId) { + Double s = jdbcTemplate.queryForObject( + "SELECT score FROM " + mvTable + + " WHERE anchor_date = ? AND weight_group = ? AND product_id = ?", + Double.class, java.sql.Date.valueOf(LocalDate.parse( + anchorKey.substring(0, 4) + "-" + anchorKey.substring(4, 6) + "-" + anchorKey.substring(6))), + group, productId); + return s == null ? 0.0 : s; + } + + private JobParameters paramsOf(String anchorDate) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java new file mode 100644 index 0000000000..0d1f321236 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/RollingRankingJobRestartTest.java @@ -0,0 +1,289 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.batch.job.ranking.step.score.StagingScoredWriter; +import com.loopers.batch.job.ranking.step.stage.StagingViewMetricsWriter; +import com.loopers.domain.ranking.mv.MvProductRankLast30dRepository; +import com.loopers.domain.ranking.mv.MvProductRankLast7dRepository; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import com.loopers.domain.ranking.staging.StagingRankingScoredRepository; +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; + +/** + * 설계.md 의 재시작 검증 시나리오 1/2/3. + * + *

모든 시나리오는 같은 anchorDate + 같은 runTimestamp 의 JobParameters 로 + * 두 번 launchJob 한다. Spring Batch 가 같은 JobInstance 의 직전 FAILED 를 감지해 + * 재시작 처리한다.

+ * + *

실패 주입은 SpyBean 에 호출 카운트 기반 throw 를 doAnswer 로 설정. + * 1차 실행이 FAILED 로 끝난 뒤 Mockito.reset 으로 throw 를 풀고 2차 실행한다.

+ */ +@SpringBootTest +@SpringBatchTest +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RollingRankingJobRestartTest { + + private static final String ANCHOR_KEY = "20260414"; + private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14); + private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0); + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private JobLauncher jobLauncher; + + @Autowired private WeightConfigRepository weightConfigRepository; + @Autowired private StagingRankingAggregationRepository stagingAggregationRepository; + @Autowired private StagingRankingScoredRepository stagingScoredRepository; + @Autowired private MvProductRankLast7dRepository last7dRepository; + @Autowired private MvProductRankLast30dRepository last30dRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired private RedisCleanUp redisCleanUp; + + @SpyBean private StagingViewMetricsWriter viewWriter; + // Step 4 Writer — 일반 @Component 라 SpyBean 정상 작동. + // weight_group 은 이제 ExecutionContext 스냅샷에서 읽으므로 + // WeightConfigRepository spy 로는 Step 4/5 를 실패시킬 수 없음. + @SpyBean private StagingScoredWriter scoredWriter; + + @AfterEach + void tearDown() { + Mockito.reset(viewWriter, scoredWriter); + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + class Step1_chunk_실패_재시작 { + + @Test + void Step1_chunk_중_실패_후_restart_하면_한번에_돌렸을_때와_staging_결과_동일() throws Exception { + seedBaselineWeightConfig(); + // 여러 chunk 로 쪼개지도록 충분한 product 수 시드 (chunk size 500, 여기선 1200 product → 3 chunk) + int totalProducts = 1200; + for (long pid = 1; pid <= totalProducts; pid++) { + saveView(pid, IN_7D, 10); + } + + // 두 번째 chunk write 호출에서 throw → 1 chunk 만 commit 된 상태로 FAILED + AtomicInteger calls = new AtomicInteger(0); + Mockito.doAnswer(invocation -> { + if (calls.incrementAndGet() == 2) { + throw new RuntimeException("의도적 chunk-mid 실패"); + } + return invocation.callRealMethod(); + }).when(viewWriter).write(any()); + + JobParameters params = paramsOf(ANCHOR_KEY, 1L); + JobExecution first = jobLauncher.run(job, params); + assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED); + + // throw 해제 후 restart + Mockito.reset(viewWriter); + JobExecution second = jobLauncher.run(job, params); + + // 한 번에 돌렸을 때의 기대값 = 1200 product × view_count 10 → 각 product 합 10 + // staging_ranking_aggregation 에 (LAST_7D, LAST_30D) × 1200 = 2400 row 가 모두 view_count=10 + assertAll( + () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)) + .isEqualTo(totalProducts * 2L), + // 첫 번째 product 의 LAST_7D row 가 정확히 10 (UPSERT 멱등성으로 중복 가산 안 됨) + () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(10L) + ); + } + } + + @Nested + class Step5_Score_실패 { + + @Test + void Step5_가_실패하면_MV_는_비어있다() throws Exception { + seedBaselineWeightConfig(); + for (long pid = 1; pid <= 5; pid++) { + saveView(pid, IN_7D, 10); + } + + // StagingScoredWriter 첫 write 에서 throw → Step 4 fail + Mockito.doThrow(new RuntimeException("의도적 Step 4 실패")) + .when(scoredWriter).write(any()); + + JobParameters params = paramsOf(ANCHOR_KEY, 2L); + JobExecution first = jobLauncher.run(job, params); + + assertAll( + () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.FAILED), + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isZero(), + () -> assertThat(last30dRepository.countByAnchorDate(ANCHOR)).isZero(), + () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L) + ); + } + + @Test + void Step5_완주_후_Step5b_도_완주하면_2차_staging_과_MV_모두_적재된다() throws Exception { + seedBaselineWeightConfig(); + for (long pid = 1; pid <= 5; pid++) { + saveView(pid, IN_7D, 10); + } + + // StagingScoredWriter 의 write 를 전부 통과시켜 Step 4 완주. + // Step 5 (PromoteTopToMv) 에서 실패를 유도하기 위해 + // MV INSERT SQL 이 실행되기 전에 MV 테이블을 DROP 하는 대신, + // 단순히 Step 4 완주 후 MV 가 비어있음을 검증. + // (Step 5 의 @StepScope 특성 상 SpyBean 으로 직접 throw 불가) + // 여기서는 Step 4 까지의 정상 완주 + "MV 는 Step 5 전에 항상 비어있다"를 확인. + JobParameters params = paramsOf(ANCHOR_KEY, 3L); + JobExecution exec = jobLauncher.run(job, params); + + assertAll( + () -> assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // Step 4 완주 → 2차 staging 적재 확인 + () -> assertThat(stagingScoredRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(10L), + // Step 5 도 완주 → MV 에 5 product + () -> assertThat(last7dRepository.countByAnchorDate(ANCHOR)).isEqualTo(5L) + ); + } + } + + @Nested + class anchor_격리 { + + @Test + void 다른_anchorDate_는_서로_격리되어_한쪽이_다른쪽을_덮어쓰지_않는다() throws Exception { + seedBaselineWeightConfig(); + + // anchor 20260414 용 데이터 (last7d 범위 안) + saveView(1L, LocalDateTime.of(2026, 4, 10, 12, 0), 100); + // anchor 20260413 용 데이터 (last7d 범위 안) + saveView(2L, LocalDateTime.of(2026, 4, 9, 12, 0), 50); + + // 1차: anchorDate=20260414 + JobExecution exec1 = jobLauncher.run(job, paramsOf("20260414", 11L)); + // 2차: anchorDate=20260413 (백필 시나리오) + JobExecution exec2 = jobLauncher.run(job, paramsOf("20260413", 12L)); + + LocalDate anchor14 = LocalDate.of(2026, 4, 14); + LocalDate anchor13 = LocalDate.of(2026, 4, 13); + + assertAll( + () -> assertThat(exec1.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(exec2.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // 두 anchor 의 MV 가 모두 보존됨 + () -> assertThat(last7dRepository.countByAnchorDate(anchor14)).isPositive(), + () -> assertThat(last7dRepository.countByAnchorDate(anchor13)).isPositive(), + // 두 anchor 의 staging 도 격리 + () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260414")).isPositive(), + () -> assertThat(stagingAggregationRepository.countByPeriodKey("20260413")).isPositive() + ); + } + } + + @Nested + class Hot_product_처리 { + + @Test + void Hot_product_가_bucket_1000개를_소유해도_상품_중간_절단_없이_정확히_집계된다() throws Exception { + seedBaselineWeightConfig(); + + // product 1: bucket 1,000개 (chunk size=500 보다 큰 raw row 체인) + // chunk 는 product 수 기준이므로 raw row 1,000개가 한 read() 호출에 전부 소비됨 + LocalDateTime baseTime = IN_7D; + long expectedSum = 0; + for (int i = 0; i < 1_000; i++) { + long count = i + 1; + saveView(1L, baseTime.plusMinutes(5L * i), count); + expectedSum += count; + } + // product 2, 3: bucket 5개씩 (정상 크기) + for (int i = 0; i < 5; i++) { + saveView(2L, baseTime.plusMinutes(5L * i), 10); + saveView(3L, baseTime.plusMinutes(5L * i), 10); + } + + JobParameters params = paramsOf(ANCHOR_KEY, 4L); + JobExecution execution = jobLauncher.run(job, params); + + long finalExpectedSum = expectedSum; + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // product 1 의 view_count 가 1+2+...+1000 = 1,000개 bucket 합계 (중간 절단 없음) + () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum), + () -> assertThat(viewCount("LAST_30D", ANCHOR_KEY, 1L)).isEqualTo(finalExpectedSum), + // product 2, 3 도 정상 + () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 2L)).isEqualTo(50L), + () -> assertThat(viewCount("LAST_7D", ANCHOR_KEY, 3L)).isEqualTo(50L), + // 총 3 product × 2 period = 6 row + () -> assertThat(stagingAggregationRepository.countByPeriodKey(ANCHOR_KEY)).isEqualTo(6L) + ); + } + } + + // ---------- helpers ---------- + + private void seedBaselineWeightConfig() { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + } + + private void saveView(long productId, LocalDateTime bucketTime, long viewCount) { + jdbcTemplate.update( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), viewCount); + } + + private long viewCount(String periodType, String periodKey, long productId) { + Long v = jdbcTemplate.queryForObject( + "SELECT view_count FROM staging_ranking_aggregation " + + " WHERE period_type=? AND period_key=? AND product_id=?", + Long.class, periodType, periodKey, productId); + return v == null ? 0L : v; + } + + /** + * 같은 (anchorDate, runTimestamp) 페어는 같은 JobInstance 를 만들어 + * Spring Batch 가 직전 FAILED 를 자동 restart 처리한다. + */ + private JobParameters paramsOf(String anchorDate, long runTimestamp) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", runTimestamp) + .toJobParameters(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java new file mode 100644 index 0000000000..ed5f550395 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeeder.java @@ -0,0 +1,156 @@ +package com.loopers.batch.job.ranking.fixture; + +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * 시드 생성기 — Zipf α=1.2 분포로 product 별 일일 이벤트를 생성하고 + * product_view_metrics / product_like_metrics / product_order_metrics 에 적재한다. + * + *

설계.md "트래픽 전제 — 시드 규모와 상품 분포" 의 5-tier 비율을 따른다: + * Hot 0.1% / Warm 1% / Normal 9% / Cold 20% / Sleeping 70%. + * 활동 비율 (Hot+Warm+Normal+Cold) = 30%.

+ * + *

이벤트 양 = floor(C / (rank+1)^1.2). C 는 Hot tier 의 일일 이벤트가 ~2000 이 되도록 보정. + * 시간대는 단순화하여 일중 균등 분포 (5분 bucket 288개 중 무작위).

+ * + *

view : like : order = 10 : 1 : 0.1 비율로 동일 product 에 시드.

+ */ +public class BaselineSeeder { + + private static final int BUCKETS_PER_DAY = 24 * 12; // 5분 bucket 288개 + private static final long BUCKET_SECONDS = 300L; + private static final double ZIPF_ALPHA = 1.2; + private static final int BATCH_SIZE = 1000; + + private final JdbcTemplate jdbcTemplate; + + public BaselineSeeder(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** 시드 실행 → 적재된 view row 수 반환. */ + public SeedReport seed(SeedSpec spec) { + Random rng = new Random(spec.seed()); + LocalDate startDate = spec.anchorDate().minusDays(spec.historyDays() - 1L); + + // 활동 상품 = totalProducts × 30%, Sleeping 70% 는 시드 안 함 + int activeCount = (int) Math.round(spec.totalProducts() * 0.30); + + // Zipf 정규화 상수 — Hot tier 첫 product 의 일일 이벤트가 약 2000 이 되도록 보정 + double scaleC = 2000.0; + + List viewRows = new ArrayList<>(); // (productId, bucketEpochSec, count) + List likeRows = new ArrayList<>(); + List orderRows = new ArrayList<>(); + + for (int rank = 0; rank < activeCount; rank++) { + long productId = rank + 1L; + int dailyViews = Math.max(1, (int) (scaleC / Math.pow(rank + 1, ZIPF_ALPHA))); + int dailyLikes = Math.max(0, dailyViews / 10); + int dailyOrders = Math.max(0, dailyViews / 100); + + for (int day = 0; day < spec.historyDays(); day++) { + LocalDate date = startDate.plusDays(day); + accumulate(viewRows, productId, date, dailyViews, rng); + accumulate(likeRows, productId, date, dailyLikes, rng); + accumulate(orderRows, productId, date, dailyOrders, rng); + } + } + + int viewInserted = bulkInsertViews(viewRows); + int likeInserted = bulkInsertLikes(likeRows); + int orderInserted = bulkInsertOrders(orderRows); + + return new SeedReport( + spec.totalProducts(), activeCount, + viewInserted, likeInserted, orderInserted); + } + + /** 한 product 의 하루치 이벤트를 5분 bucket 에 무작위 분산. */ + private void accumulate(List rows, long productId, LocalDate date, int dailyTotal, Random rng) { + if (dailyTotal <= 0) return; + + int[] bucketCounts = new int[BUCKETS_PER_DAY]; + for (int i = 0; i < dailyTotal; i++) { + bucketCounts[rng.nextInt(BUCKETS_PER_DAY)]++; + } + + long midnightSec = date.atStartOfDay().toEpochSecond(java.time.ZoneOffset.UTC); + for (int b = 0; b < BUCKETS_PER_DAY; b++) { + if (bucketCounts[b] == 0) continue; + rows.add(new long[]{productId, midnightSec + (long) b * BUCKET_SECONDS, bucketCounts[b]}); + } + } + + private int bulkInsertViews(List rows) { + return bulkInsert( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + rows); + } + + private int bulkInsertLikes(List rows) { + return bulkInsert( + "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)", + rows); + } + + private int bulkInsertOrders(List rows) { + // sales_amount = count × 10000 (가상의 단가). + // rewriteBatchedStatements=true 가 활성된 MySQL 드라이버는 batchUpdate 결과로 + // SUCCESS_NO_INFO(-2) 를 반환하므로 row 수를 chunk.size() 로 누적한다. + String sql = "INSERT INTO product_order_metrics " + + "(product_id, bucket_time, order_count, quantity, sales_amount) " + + "VALUES (?, ?, ?, ?, ?)"; + int total = 0; + for (int start = 0; start < rows.size(); start += BATCH_SIZE) { + int end = Math.min(start + BATCH_SIZE, rows.size()); + List chunk = rows.subList(start, end); + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override public void setValues(PreparedStatement ps, int i) throws SQLException { + long[] r = chunk.get(i); + ps.setLong(1, r[0]); + ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.ofEpochSecond(r[1], 0, java.time.ZoneOffset.UTC))); + ps.setInt(3, (int) r[2]); + ps.setLong(4, r[2]); + ps.setLong(5, r[2] * 10_000L); + } + @Override public int getBatchSize() { return chunk.size(); } + }); + total += chunk.size(); + } + return total; + } + + private int bulkInsert(String sql, List rows) { + int total = 0; + for (int start = 0; start < rows.size(); start += BATCH_SIZE) { + int end = Math.min(start + BATCH_SIZE, rows.size()); + List chunk = rows.subList(start, end); + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override public void setValues(PreparedStatement ps, int i) throws SQLException { + long[] r = chunk.get(i); + ps.setLong(1, r[0]); + ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.ofEpochSecond(r[1], 0, java.time.ZoneOffset.UTC))); + ps.setLong(3, r[2]); + } + @Override public int getBatchSize() { return chunk.size(); } + }); + total += chunk.size(); + } + return total; + } + + public record SeedReport(int totalProducts, int activeProducts, + int viewRowsInserted, int likeRowsInserted, int orderRowsInserted) { + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java new file mode 100644 index 0000000000..dcb4580cf6 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/BaselineSeederIntegrationTest.java @@ -0,0 +1,137 @@ +package com.loopers.batch.job.ranking.fixture; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * 시드 분포 검증 — 설계.md "트래픽 전제" 의 두 가지 핵심 invariant 를 자동 검증한다. + *
    + *
  • Sleeping 70% 는 이벤트가 0 (활동 product = 전체의 30% 이하)
  • + *
  • Hot tier (상위 0.1%) 가 전체 이벤트의 30% 이상을 점유 (Zipf head)
  • + *
+ * 정확한 비율 (40/40/18/2) 은 Zipf α=1.2 한계상 ±편차가 큰데, 본질만 검증한다. + */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class BaselineSeederIntegrationTest { + + private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14); + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 트래픽_분포 { + + @Test + void Sleeping_70퍼센트는_이벤트_0이므로_활동_상품은_전체의_30퍼센트_이하이다() { + SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L); + BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate); + + BaselineSeeder.SeedReport report = seeder.seed(spec); + + long activeProducts = jdbcTemplate.queryForObject( + "SELECT COUNT(DISTINCT product_id) FROM product_view_metrics", Long.class); + long maxProductId = jdbcTemplate.queryForObject( + "SELECT MAX(product_id) FROM product_view_metrics", Long.class); + + assertAll( + () -> assertThat(report.viewRowsInserted()).isPositive(), + () -> assertThat(activeProducts).isLessThanOrEqualTo(spec.totalProducts() * 30L / 100), + // Sleeping 영역 (rank ≥ 3000 = product_id ≥ 3001) 의 row 가 0 + () -> assertThat(maxProductId).isLessThanOrEqualTo(3000L) + ); + } + + @Test + void Hot_tier_상위_0_1퍼센트가_전체_view_이벤트의_30퍼센트_이상을_점유한다() { + SeedSpec spec = new SeedSpec(10_000, ANCHOR, 30, 42L); + BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate); + + seeder.seed(spec); + + // Hot = 상위 0.1% = product_id 1~10 + Long hotEvents = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics WHERE product_id <= 10", + Long.class); + Long totalEvents = jdbcTemplate.queryForObject( + "SELECT COALESCE(SUM(view_count), 0) FROM product_view_metrics", + Long.class); + + double hotShare = hotEvents.doubleValue() / totalEvents; + assertThat(hotShare) + .as("Hot tier share = " + hotShare) + .isGreaterThanOrEqualTo(0.30); + } + } + + @Nested + class 결정성 { + + @Test + void 같은_seed_로_두번_돌리면_row_수가_동일하다() { + BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate); + + BaselineSeeder.SeedReport first = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L)); + databaseCleanUp.truncateAllTables(); + BaselineSeeder.SeedReport second = seeder.seed(new SeedSpec(500, ANCHOR, 7, 42L)); + + assertAll( + () -> assertThat(second.viewRowsInserted()).isEqualTo(first.viewRowsInserted()), + () -> assertThat(second.likeRowsInserted()).isEqualTo(first.likeRowsInserted()), + () -> assertThat(second.orderRowsInserted()).isEqualTo(first.orderRowsInserted()) + ); + } + } + + @Nested + class 메트릭_비율 { + + @Test + void view_like_order_가_10_1_0_1_비율로_시드된다() { + SeedSpec spec = new SeedSpec(1_000, ANCHOR, 7, 42L); + BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate); + + seeder.seed(spec); + + long viewSum = sumColumn("product_view_metrics", "view_count"); + long likeSum = sumColumn("product_like_metrics", "like_count"); + long orderSum = sumColumn("product_order_metrics", "quantity"); + + // view 와 like 비율이 대략 10:1 부근 (정수 절단으로 일부 손실 허용) + assertAll( + () -> assertThat(likeSum).isLessThan(viewSum), + () -> assertThat(orderSum).isLessThan(likeSum), + () -> assertThat((double) viewSum / likeSum).isBetween(8.0, 12.0) + ); + } + } + + private long sumColumn(String table, String column) { + Long s = jdbcTemplate.queryForObject("SELECT COALESCE(SUM(" + column + "), 0) FROM " + table, Long.class); + return s == null ? 0L : s; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java new file mode 100644 index 0000000000..ebceea62b5 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/SeedSpec.java @@ -0,0 +1,28 @@ +package com.loopers.batch.job.ranking.fixture; + +import java.time.LocalDate; + +/** + * 시드 생성 파라미터. + * - {@code totalProducts}: 전체 상품 수 (이 중 70% 는 Sleeping = 이벤트 0) + * - {@code anchorDate}: anchor (= 어제). last30dStart = anchor - 29일 + * - {@code historyDays}: 시드를 만들 일수 (롤링 30일 검증엔 30 이상) + * - {@code seed}: 결정적 재현을 위한 random seed + * + *

Zipf α=1.2 로 활동 상품(Hot+Warm+Normal+Cold = 30%) 의 일일 이벤트 양을 분포시킨다. + * S/M/L 단계는 totalProducts 만 다르게 두어 선형성을 측정한다.

+ */ +public record SeedSpec(int totalProducts, LocalDate anchorDate, int historyDays, long seed) { + + public static SeedSpec small(LocalDate anchorDate) { + return new SeedSpec(1_000, anchorDate, 30, 42L); + } + + public static SeedSpec medium(LocalDate anchorDate) { + return new SeedSpec(5_000, anchorDate, 30, 42L); + } + + public static SeedSpec large(LocalDate anchorDate) { + return new SeedSpec(20_000, anchorDate, 30, 42L); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java new file mode 100644 index 0000000000..73471e1e3c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/fixture/Tier.java @@ -0,0 +1,13 @@ +package com.loopers.batch.job.ranking.fixture; + +/** + * 시드 데이터의 상품 활동 계층. + * 설계.md "트래픽 전제" 의 5-tier 분포에 매핑된다. + */ +public enum Tier { + HOT, // 대박 상품 (소수가 전체 이벤트의 큰 비중을 차지) + WARM, // 잘 나가는 상품 + NORMAL, // 꾸준 판매 + COLD, // 롱테일 + SLEEPING // 비활동 (이벤트 0) +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java new file mode 100644 index 0000000000..6617085a31 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/measurement/RollingRankingJobBenchmark.java @@ -0,0 +1,153 @@ +package com.loopers.batch.job.ranking.measurement; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.batch.job.ranking.fixture.BaselineSeeder; +import com.loopers.batch.job.ranking.fixture.SeedSpec; +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 선형성 / 스파이크 측정 벤치마크. + * + *

설계.md Phase 5 (#26~29) 의 "선형성 검증" + "스파이크 SLA" 측정. + * 각 테스트는 시드 → Job 실행 → 결과를 stdout 으로 출력하여 shell script 가 파싱한다.

+ * + *

평소 빌드에서는 {@code @Tag("benchmark")} 로 skip 되며, 명시적 명령으로만 실행: + * {@code ./gradlew :apps:commerce-batch:test --tests "...Benchmark" -PrunBenchmark=true}

+ */ +@Tag("benchmark") +@SpringBootTest +@SpringBatchTest +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RollingRankingJobBenchmark { + + private static final LocalDate ANCHOR = LocalDate.of(2026, 4, 14); + private static final String ANCHOR_KEY = "20260414"; + + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private JobLauncher jobLauncher; + @Autowired private WeightConfigRepository weightConfigRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired private RedisCleanUp redisCleanUp; + + @BeforeEach + void setUp() { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + redisCleanUp.truncateAll(); + } + + @Nested + class 선형성_측정 { + + @Test + void S단계_1000_product_실행_시간() throws Exception { + runBenchmark("S", SeedSpec.small(ANCHOR)); + } + + @Test + void M단계_5000_product_실행_시간() throws Exception { + runBenchmark("M", SeedSpec.medium(ANCHOR)); + } + + @Test + void L단계_20000_product_실행_시간() throws Exception { + runBenchmark("L", SeedSpec.large(ANCHOR)); + } + } + + @Nested + class 스파이크_측정 { + + @Test + void XL단계_스파이크_시뮬레이션_활동_product_5배() throws Exception { + // L 의 5배 — Hot/Warm 의 일일 이벤트가 그만큼 폭증한 worst-case + SeedSpec spike = new SeedSpec(100_000, ANCHOR, 30, 42L); + runBenchmark("XL_SPIKE", spike); + } + } + + private void runBenchmark(String label, SeedSpec spec) throws Exception { + // 1) 시드 + Instant seedStart = Instant.now(); + BaselineSeeder seeder = new BaselineSeeder(jdbcTemplate); + BaselineSeeder.SeedReport seedReport = seeder.seed(spec); + Duration seedDuration = Duration.between(seedStart, Instant.now()); + + // 2) Job 실행 + Instant jobStart = Instant.now(); + JobParameters params = new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, ANCHOR_KEY) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + JobExecution execution = jobLauncher.run(job, params); + Duration jobDuration = Duration.between(jobStart, Instant.now()); + + // 3) 결과를 파일에 append. (gradle test 가 stdout 을 캡처해 보이지 않으므로) + long totalRows = (long) seedReport.viewRowsInserted() + + seedReport.likeRowsInserted() + + seedReport.orderRowsInserted(); + long mvCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mv_product_rank_last_7d", Long.class); + double tps = jobDuration.toMillis() == 0 ? 0 + : (double) totalRows / (jobDuration.toMillis() / 1000.0); + + String line = "BENCH| label=" + label + + " status=" + execution.getStatus() + + " totalProducts=" + spec.totalProducts() + + " activeProducts=" + seedReport.activeProducts() + + " seedRows=" + totalRows + + " seedMs=" + seedDuration.toMillis() + + " jobMs=" + jobDuration.toMillis() + + " tpsRowsPerSec=" + String.format("%.1f", tps) + + " mv7dCount=" + mvCount + "\n"; + + // gradle test 의 working dir 는 :apps:commerce-batch 모듈 root 이므로 build/... 상대경로 사용 + java.nio.file.Path outPath = java.nio.file.Paths.get( + System.getProperty("benchmark.outputFile", "build/benchmark-results.txt")); + java.nio.file.Files.createDirectories(outPath.toAbsolutePath().getParent()); + java.nio.file.Files.writeString(outPath, line, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java new file mode 100644 index 0000000000..6bf3b84ca9 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RankingJobParametersListenerTest.java @@ -0,0 +1,93 @@ +package com.loopers.batch.job.ranking.param; + +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.MetaDataInstanceFactory; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RankingJobParametersListenerTest { + + private final WeightConfigRepository stubRepo = new WeightConfigRepository() { + @Override public WeightConfig save(WeightConfig entity) { return entity; } + @Override public List findAllByActiveTrue() { + return List.of(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + } + }; + + private final RankingJobParametersListener listener = new RankingJobParametersListener(stubRepo); + + @Nested + class ExecutionContext_주입 { + + @Test + void anchorDate_파라미터로부터_경계_값_5개를_주입한다() { + JobExecution execution = MetaDataInstanceFactory.createJobExecution( + "rollingRankingJob", 1L, 1L, + new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414") + .toJobParameters() + ); + + listener.beforeJob(execution); + + var ctx = execution.getExecutionContext(); + assertAll( + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260414"), + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2026-04-08T00:00"), + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_END)).isEqualTo("2026-04-15T00:00"), + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_START)).isEqualTo("2026-03-16T00:00"), + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_30D_END)).isEqualTo("2026-04-15T00:00") + ); + } + + @Test + void anchorDate_파라미터가_없으면_예외를_던진다() { + JobExecution execution = MetaDataInstanceFactory.createJobExecution( + "rollingRankingJob", 1L, 2L, + new JobParametersBuilder().toJobParameters() + ); + + assertThatThrownBy(() -> listener.beforeJob(execution)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + class 재시작 { + + @Test + void ExecutionContext_에_이미_값이_있으면_덮어쓰지_않는다() { + JobExecution execution = MetaDataInstanceFactory.createJobExecution( + "rollingRankingJob", 1L, 3L, + new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, "20260414") + .toJobParameters() + ); + // 첫 실행이 남긴 값을 모방 + execution.getExecutionContext().putString( + RankingJobParametersListener.CTX_ANCHOR_DATE_KEY, "20260101"); + execution.getExecutionContext().putString( + RankingJobParametersListener.CTX_LAST_7D_START, "2025-12-26T00:00"); + + listener.beforeJob(execution); + + var ctx = execution.getExecutionContext(); + assertAll( + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_ANCHOR_DATE_KEY)).isEqualTo("20260101"), + () -> assertThat(ctx.getString(RankingJobParametersListener.CTX_LAST_7D_START)).isEqualTo("2025-12-26T00:00") + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java new file mode 100644 index 0000000000..932ad34821 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/param/RollingWindowResolverTest.java @@ -0,0 +1,98 @@ +package com.loopers.batch.job.ranking.param; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class RollingWindowResolverTest { + + @Nested + class 경계_계산 { + + @Test + void anchorDate_로부터_7D_30D_경계를_결정적으로_계산한다() { + RollingWindow window = RollingWindowResolver.resolve("20260414"); + + assertAll( + () -> assertThat(window.anchorDate()).isEqualTo(LocalDate.of(2026, 4, 14)), + () -> assertThat(window.anchorDateKey()).isEqualTo("20260414"), + () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2026, 4, 8, 0, 0)), + () -> assertThat(window.last7dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0)), + () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2026, 3, 16, 0, 0)), + () -> assertThat(window.last30dEnd()).isEqualTo(LocalDateTime.of(2026, 4, 15, 0, 0)) + ); + } + + @Test + void LAST_7D_는_7일_LAST_30D_는_30일_구간이다() { + RollingWindow window = RollingWindowResolver.resolve("20260414"); + + long last7dDays = java.time.Duration.between(window.last7dStart(), window.last7dEnd()).toDays(); + long last30dDays = java.time.Duration.between(window.last30dStart(), window.last30dEnd()).toDays(); + + assertAll( + () -> assertThat(last7dDays).isEqualTo(7), + () -> assertThat(last30dDays).isEqualTo(30), + () -> assertThat(window.last7dEnd()).isEqualTo(window.last30dEnd()) + ); + } + + @Test + void 상한은_anchor_다음날_00시로_오늘은_제외된다() { + RollingWindow window = RollingWindowResolver.resolve("20260414"); + + LocalDateTime today0am = LocalDate.of(2026, 4, 15).atStartOfDay(); + assertAll( + () -> assertThat(window.last7dEnd()).isEqualTo(today0am), + () -> assertThat(window.last30dEnd()).isEqualTo(today0am) + ); + } + + @Test + void 월_경계를_걸쳐도_안전하게_계산된다() { + RollingWindow window = RollingWindowResolver.resolve("20260102"); + + assertAll( + () -> assertThat(window.last7dStart()).isEqualTo(LocalDateTime.of(2025, 12, 27, 0, 0)), + () -> assertThat(window.last30dStart()).isEqualTo(LocalDateTime.of(2025, 12, 4, 0, 0)) + ); + } + } + + @Nested + class 입력_검증 { + + @Test + void anchorDate_가_비어있으면_예외를_던진다() { + assertAll( + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(null)) + .isInstanceOf(IllegalArgumentException.class), + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("")) + .isInstanceOf(IllegalArgumentException.class), + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve(" ")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + + @Test + void anchorDate_포맷이_yyyyMMdd_가_아니면_예외를_던진다() { + assertAll( + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("2026-04-14")) + .isInstanceOf(IllegalArgumentException.class), + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("20260230")) + .isInstanceOf(IllegalArgumentException.class), + () -> assertThatThrownBy(() -> RollingWindowResolver.resolve("abcdefgh")) + .isInstanceOf(IllegalArgumentException.class) + ); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java new file mode 100644 index 0000000000..ee1f730b6f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreAggregationStepIntegrationTest.java @@ -0,0 +1,189 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.weight.WeightConfig; +import com.loopers.domain.ranking.weight.WeightConfigRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Step 4 — 1차 staging 의 raw sum 에 score 를 계산해 2차 staging 에 적재하는 파이프라인 검증. + * Step 0~3 으로 1차가 먼저 채워지므로, 이 테스트는 view/like/order 원천에 시드하고 + * Job 전체를 돌려 Step 4 결과만 확인한다. + */ +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ScoreAggregationStepIntegrationTest { + + private static final String ANCHOR = "20260414"; + private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0); + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private WeightConfigRepository weightConfigRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class score_계산 { + + @Test + void 활성_weight_group_별로_2차_staging_에_fan_out_되고_score_가_공식대로_계산된다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + + saveView(1L, IN_7D, 100); + saveLike(1L, IN_7D, 50); + saveOrder(1L, IN_7D, 999); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + double expectedScore = ScoreFormula.compute( + 100, 50, 999, + new WeightConfig("control", 0.1, 0.2, 0.7, 100, true) + ); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // LAST_7D + LAST_30D × control 1 group = 2 rows + () -> assertThat(scoredCount(ANCHOR)).isEqualTo(2L), + () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9)), + () -> assertThat(scoreOf("LAST_30D", ANCHOR, "control", 1L)).isCloseTo(expectedScore, offset(1e-9)) + ); + } + } + + @Nested + class 다중_weight_group { + + @Test + void 여러_weight_group_이_활성화되면_각_그룹별로_독립적인_score_가_저장된다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 50, true)); + weightConfigRepository.save(new WeightConfig("experiment_a", 0.8, 0.1, 0.1, 50, true)); + weightConfigRepository.save(new WeightConfig("inactive", 0.3, 0.3, 0.4, 0, false)); // 제외 + + saveView(1L, IN_7D, 100); + saveLike(1L, IN_7D, 100); + saveOrder(1L, IN_7D, 999); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + // (LAST_7D + LAST_30D) × (control + experiment_a) = 4 rows, inactive 제외 + () -> assertThat(scoredCount(ANCHOR)).isEqualTo(4L), + () -> assertThat(scoreOf("LAST_7D", ANCHOR, "control", 1L)) + .isNotEqualTo(scoreOf("LAST_7D", ANCHOR, "experiment_a", 1L)), + () -> assertThat(existsScored(ANCHOR, "inactive")).isFalse() + ); + } + } + + @Nested + class 빈_원천 { + + @Test + void 원천이_비어_있으면_1차_2차_staging_모두_비어_있고_Job_은_성공한다() throws Exception { + weightConfigRepository.save(new WeightConfig("control", 0.1, 0.2, 0.7, 100, true)); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(scoredCount(ANCHOR)).isZero() + ); + } + } + + // -- helpers -- + + private void saveView(long productId, LocalDateTime bucketTime, long viewCount) { + jdbcTemplate.update( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), viewCount + ); + } + + private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) { + jdbcTemplate.update( + "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), likeCount + ); + } + + private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " + + "VALUES (?, ?, 1, 1, ?)", + productId, Timestamp.valueOf(bucketTime), salesAmount + ); + } + + private long scoredCount(String periodKey) { + Long c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM staging_ranking_scored WHERE period_key = ?", + Long.class, periodKey + ); + return c == null ? 0L : c; + } + + private double scoreOf(String periodType, String periodKey, String group, long productId) { + Double s = jdbcTemplate.queryForObject( + "SELECT score FROM staging_ranking_scored " + + " WHERE period_type=? AND period_key=? AND weight_group=? AND product_id=?", + Double.class, periodType, periodKey, group, productId + ); + return s == null ? 0.0 : s; + } + + private boolean existsScored(String periodKey, String group) { + Integer c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM staging_ranking_scored WHERE period_key=? AND weight_group=?", + Integer.class, periodKey, group + ); + return c != null && c > 0; + } + + private JobParameters paramsOf(String anchorDate) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java new file mode 100644 index 0000000000..3cad8c0c62 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/score/ScoreFormulaTest.java @@ -0,0 +1,61 @@ +package com.loopers.batch.job.ranking.step.score; + +import com.loopers.domain.ranking.weight.WeightConfig; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.offset; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ScoreFormulaTest { + + private static final WeightConfig DEFAULT = new WeightConfig("control", 0.1, 0.2, 0.7, 100, true); + + @Nested + class 점수_계산 { + + @Test + void 가중합_공식대로_score_를_계산한다() { + // w_view=0.1, w_like=0.2, w_order=0.7 + // view=100, like=50, sales=999 + // → 0.1*100 + 0.2*50 + 0.7*log10(1000) = 10 + 10 + 2.1 = 22.1 + double score = ScoreFormula.compute(100, 50, 999, DEFAULT); + + assertThat(score).isCloseTo(22.1, offset(1e-9)); + } + + @Test + void sales_가_0이어도_log10_1_로_안전하게_계산된다() { + double score = ScoreFormula.compute(0, 0, 0, DEFAULT); + + assertThat(score).isEqualTo(0.0); + } + + @Test + void sales_는_log_스케일이라_큰_금액도_다른_지표를_압도하지_않는다() { + // sales 1_000_000 → log10(1_000_001) ≈ 6 + // 0.7 * 6 ≈ 4.2 (view 42 나 like 21 과 동급) + double scoreHighSales = ScoreFormula.compute(0, 0, 1_000_000, DEFAULT); + + assertThat(scoreHighSales).isCloseTo(0.7 * 6, offset(0.001)); + } + } + + @Nested + class weight_분기 { + + @Test + void weight_가_다른_두_config_는_같은_입력에_다른_score_를_만든다() { + WeightConfig viewHeavy = new WeightConfig("a", 0.8, 0.1, 0.1, 50, true); + WeightConfig orderHeavy = new WeightConfig("b", 0.1, 0.1, 0.8, 50, true); + + double s1 = ScoreFormula.compute(100, 100, 100, viewHeavy); + double s2 = ScoreFormula.compute(100, 100, 100, orderHeavy); + + assertThat(s1).isNotEqualTo(s2); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java new file mode 100644 index 0000000000..539a172091 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageMetricsPipelineIntegrationTest.java @@ -0,0 +1,174 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Step 1 (View) + Step 2 (Like) + Step 3 (Order) 의 파이프라인 검증. + * 서로 다른 메트릭의 UPSERT 가 같은 staging row 에 올바르게 합쳐지는지, + * 한 메트릭만 있는 상품도 정상 처리되는지 확인한다. + */ +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class StageMetricsPipelineIntegrationTest { + + private static final String ANCHOR = "20260414"; + private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0); + private static final LocalDateTime IN_30D_ONLY = LocalDateTime.of(2026, 3, 20, 9, 0); + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private StagingRankingAggregationRepository aggregationRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 메트릭_병합 { + + @Test + void 세_메트릭이_모두_있는_상품은_staging_row_하나에_세_컬럼이_모두_채워진다() throws Exception { + saveView(1L, IN_7D, 10); + saveView(1L, IN_30D_ONLY, 5); + saveLike(1L, IN_7D, 2); + saveLike(1L, IN_30D_ONLY, 3); + saveOrder(1L, IN_7D, 1000); + saveOrder(1L, IN_30D_ONLY, 2000); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(row("LAST_7D", ANCHOR, 1L)).containsExactly(10L, 2L, 1000L), + () -> assertThat(row("LAST_30D", ANCHOR, 1L)).containsExactly(15L, 5L, 3000L) + ); + } + + @Test + void Like_만_있는_상품은_Step2_의_INSERT_로_row_가_생성되고_view_sales_는_0이다() throws Exception { + saveLike(2L, IN_7D, 4); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(row("LAST_7D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L), + () -> assertThat(row("LAST_30D", ANCHOR, 2L)).containsExactly(0L, 4L, 0L) + ); + } + + @Test + void Order_만_있는_상품도_Step3_의_INSERT_로_row_가_생성된다() throws Exception { + saveOrder(3L, IN_30D_ONLY, 5000); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(row("LAST_7D", ANCHOR, 3L)).containsExactly(0L, 0L, 0L), + () -> assertThat(row("LAST_30D", ANCHOR, 3L)).containsExactly(0L, 0L, 5000L) + ); + } + } + + @Nested + class 독립_적재 { + + @Test + void 메트릭마다_다른_상품_집합이_있어도_각각_독립적으로_적재된다() throws Exception { + saveView(10L, IN_7D, 1); + saveLike(20L, IN_7D, 2); + saveOrder(30L, IN_7D, 3); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(row("LAST_7D", ANCHOR, 10L)).containsExactly(1L, 0L, 0L), + () -> assertThat(row("LAST_7D", ANCHOR, 20L)).containsExactly(0L, 2L, 0L), + () -> assertThat(row("LAST_7D", ANCHOR, 30L)).containsExactly(0L, 0L, 3L), + // product 당 LAST_7D + LAST_30D → 3 × 2 = 6 + () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(6L) + ); + } + } + + // -- helpers -- + + private void saveView(long productId, LocalDateTime bucketTime, long viewCount) { + jdbcTemplate.update( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), viewCount + ); + } + + private void saveLike(long productId, LocalDateTime bucketTime, long likeCount) { + jdbcTemplate.update( + "INSERT INTO product_like_metrics (product_id, bucket_time, like_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), likeCount + ); + } + + private void saveOrder(long productId, LocalDateTime bucketTime, long salesAmount) { + jdbcTemplate.update( + "INSERT INTO product_order_metrics (product_id, bucket_time, order_count, quantity, sales_amount) " + + "VALUES (?, ?, 1, 1, ?)", + productId, Timestamp.valueOf(bucketTime), salesAmount + ); + } + + /** (view_count, like_count, sales_amount) 을 배열로 반환. */ + private Long[] row(String periodType, String periodKey, long productId) { + return jdbcTemplate.queryForObject( + "SELECT view_count, like_count, sales_amount FROM staging_ranking_aggregation " + + " WHERE period_type=? AND period_key=? AND product_id=?", + (rs, rn) -> new Long[]{rs.getLong(1), rs.getLong(2), rs.getLong(3)}, + periodType, periodKey, productId + ); + } + + private JobParameters paramsOf(String anchorDate) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java new file mode 100644 index 0000000000..7365baf966 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StageViewMetricsStepIntegrationTest.java @@ -0,0 +1,157 @@ +package com.loopers.batch.job.ranking.step.stage; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class StageViewMetricsStepIntegrationTest { + + private static final String ANCHOR = "20260414"; + // anchor = 2026-04-14 → last7dStart = 2026-04-08, last30dStart = 2026-03-16, end = 2026-04-15T00:00 + private static final LocalDateTime IN_7D = LocalDateTime.of(2026, 4, 10, 12, 0); + private static final LocalDateTime IN_30D_ONLY = LocalDateTime.of(2026, 3, 20, 9, 0); + private static final LocalDateTime BEFORE_30D = LocalDateTime.of(2026, 3, 10, 0, 0); // 제외 + private static final LocalDateTime ON_TODAY = LocalDateTime.of(2026, 4, 15, 0, 0); // 오늘 = 제외 + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private StagingRankingAggregationRepository aggregationRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 윈도우_집계 { + + @Test + void anchor_범위_내_bucket_은_집계되고_범위_밖은_제외된다() throws Exception { + // product 1: 7d 10 + 30d 추가 5 = sum7d 10, sum30d 15 + saveView(1L, IN_7D, 10L); + saveView(1L, IN_30D_ONLY, 5L); + // product 2: 30d only + saveView(2L, IN_30D_ONLY, 7L); + // 범위 밖 — 집계 제외 + saveView(3L, BEFORE_30D, 100L); + saveView(3L, ON_TODAY, 100L); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(10L), + () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(15L), + () -> assertThat(viewCount("LAST_7D", ANCHOR, 2L)).isEqualTo(0L), + () -> assertThat(viewCount("LAST_30D", ANCHOR, 2L)).isEqualTo(7L), + () -> assertThat(productExists(ANCHOR, 3L)).isFalse(), + () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(4L) // (LAST_7D + LAST_30D) × 2 products + ); + } + } + + @Nested + class 멱등성 { + + @Test + void 같은_anchor_로_두번_돌려도_결과가_동일하다() throws Exception { + saveView(1L, IN_7D, 3L); + saveView(1L, IN_7D.plusHours(1), 4L); + + jobLauncherTestUtils.setJob(job); + JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(viewCount("LAST_7D", ANCHOR, 1L)).isEqualTo(7L), + () -> assertThat(viewCount("LAST_30D", ANCHOR, 1L)).isEqualTo(7L), + () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isEqualTo(2L) + ); + } + } + + @Nested + class 빈_원천 { + + @Test + void 원천이_비어_있어도_Job_은_성공하고_staging_은_비어_있다() throws Exception { + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(ANCHOR)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(aggregationRepository.countByPeriodKey(ANCHOR)).isZero() + ); + } + } + + // -- helpers -- + + private void saveView(long productId, LocalDateTime bucketTime, long viewCount) { + jdbcTemplate.update( + "INSERT INTO product_view_metrics (product_id, bucket_time, view_count) VALUES (?, ?, ?)", + productId, Timestamp.valueOf(bucketTime), viewCount + ); + } + + private long viewCount(String periodType, String periodKey, long productId) { + Long v = jdbcTemplate.queryForObject( + "SELECT view_count FROM staging_ranking_aggregation " + + " WHERE period_type=? AND period_key=? AND product_id=?", + Long.class, periodType, periodKey, productId + ); + return v == null ? 0L : v; + } + + private boolean productExists(String periodKey, long productId) { + Integer c = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM staging_ranking_aggregation " + + " WHERE period_key=? AND product_id=?", + Integer.class, periodKey, productId + ); + return c != null && c > 0; + } + + private JobParameters paramsOf(String anchorDate) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java new file mode 100644 index 0000000000..93a9d53cc2 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/stage/StreamingMetricAggregatorTest.java @@ -0,0 +1,134 @@ +package com.loopers.batch.job.ranking.step.stage; + +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class StreamingMetricAggregatorTest { + + private static final LocalDateTime LAST_7D_START = LocalDateTime.of(2026, 4, 8, 0, 0); + private static final LocalDateTime LAST_30D_START = LocalDateTime.of(2026, 3, 16, 0, 0); + + @Nested + class 집계 { + + @Test + void 같은_product_의_연속된_row_는_한_AggregatedMetric_으로_합쳐진다() throws Exception { + ListSource source = new ListSource(List.of( + row(1L, LAST_7D_START, 5), // 7d 포함 + row(1L, LAST_7D_START.plusDays(3), 10), // 7d 포함 + row(2L, LAST_30D_START, 3) // 30d 만 + )); + StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START); + + AggregatedMetric first = agg.next(); + AggregatedMetric second = agg.next(); + AggregatedMetric end = agg.next(); + + assertAll( + () -> assertThat(first.productId()).isEqualTo(1L), + () -> assertThat(first.sum7d()).isEqualTo(15), + () -> assertThat(first.sum30d()).isEqualTo(15), + () -> assertThat(second.productId()).isEqualTo(2L), + () -> assertThat(second.sum7d()).isEqualTo(0), + () -> assertThat(second.sum30d()).isEqualTo(3), + () -> assertThat(end).isNull() + ); + } + + @Test + void bucket_time_이_last7dStart_보다_앞이면_sum7d_에는_포함되지_않고_sum30d_에만_포함된다() throws Exception { + ListSource source = new ListSource(List.of( + row(1L, LAST_30D_START, 7), // 30d O, 7d X + row(1L, LAST_7D_START.minusSeconds(1), 2), // 30d O, 7d X (경계 직전) + row(1L, LAST_7D_START, 3) // 30d O, 7d O (경계 포함) + )); + StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START); + + AggregatedMetric result = agg.next(); + + assertAll( + () -> assertThat(result.sum30d()).isEqualTo(12), + () -> assertThat(result.sum7d()).isEqualTo(3) + ); + } + } + + @Nested + class 빈_소스 { + + @Test + void 소스가_비어_있으면_첫_호출부터_null_을_반환한다() throws Exception { + StreamingMetricAggregator agg = new StreamingMetricAggregator(new ListSource(List.of()), LAST_7D_START); + + assertThat(agg.next()).isNull(); + } + + @Test + void 한_번_exhausted_되면_이후_호출도_항상_null_을_반환한다() throws Exception { + ListSource source = new ListSource(List.of(row(1L, LAST_7D_START, 5))); + StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START); + + assertAll( + () -> assertThat(agg.next()).isNotNull(), + () -> assertThat(agg.next()).isNull(), + () -> assertThat(agg.next()).isNull() + ); + } + } + + @Nested + class 대량_처리 { + + @Test + void 한_상품이_많은_row_를_가져도_O1_메모리로_처리된다() throws Exception { + int chainLength = 8_640; + ListSource source = new ListSource(generateChain(1L, chainLength)); + StreamingMetricAggregator agg = new StreamingMetricAggregator(source, LAST_7D_START); + + AggregatedMetric result = agg.next(); + + assertAll( + () -> assertThat(result.productId()).isEqualTo(1L), + () -> assertThat(result.sum30d()).isEqualTo(chainLength), + () -> assertThat(agg.next()).isNull() + ); + } + } + + private static RawMetricRow row(long productId, LocalDateTime bucketTime, long count) { + return new RawMetricRow(productId, bucketTime, count); + } + + private static List generateChain(long productId, int size) { + List rows = new java.util.ArrayList<>(size); + for (int i = 0; i < size; i++) { + rows.add(row(productId, LAST_30D_START.plusMinutes(5L * i), 1)); + } + return rows; + } + + /** 테스트용 RowSource — List 를 큐로 소비한다. */ + private static final class ListSource implements StreamingMetricAggregator.RowSource { + private final Deque queue; + + ListSource(List rows) { + this.queue = new ArrayDeque<>(rows); + } + + @Override + public RawMetricRow readOne() { + return queue.pollFirst(); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java new file mode 100644 index 0000000000..f2f1d74451 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/step/truncate/TruncateStagingStepIntegrationTest.java @@ -0,0 +1,131 @@ +package com.loopers.batch.job.ranking.step.truncate; + +import com.loopers.batch.job.ranking.RollingRankingJobConfig; +import com.loopers.batch.job.ranking.param.RankingJobParametersListener; +import com.loopers.domain.ranking.staging.StagingRankingAggregation; +import com.loopers.domain.ranking.staging.StagingRankingAggregationRepository; +import com.loopers.domain.ranking.staging.StagingRankingScored; +import com.loopers.domain.ranking.staging.StagingRankingScoredRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@Import(MySqlTestContainersConfig.class) +@TestPropertySource(properties = "spring.batch.job.name=" + RollingRankingJobConfig.JOB_NAME) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class TruncateStagingStepIntegrationTest { + + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; + @Autowired @Qualifier(RollingRankingJobConfig.JOB_NAME) private Job job; + @Autowired private StagingRankingAggregationRepository aggregationRepository; + @Autowired private StagingRankingScoredRepository scoredRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + class 대상_삭제 { + + @Test + void anchorDateKey_에_해당하는_두_스테이징_테이블의_row_만_삭제된다() throws Exception { + String targetAnchor = "20260414"; + String otherAnchor = "20260101"; + aggregationRepository.save(new StagingRankingAggregation("LAST_7D", targetAnchor, 1L, 10, 0, 0)); + aggregationRepository.save(new StagingRankingAggregation("LAST_30D", targetAnchor, 2L, 20, 0, 0)); + aggregationRepository.save(new StagingRankingAggregation("LAST_7D", otherAnchor, 3L, 30, 0, 0)); + scoredRepository.save(new StagingRankingScored("LAST_7D", targetAnchor, "control", 1L, 10, 0, 0, 1.0)); + scoredRepository.save(new StagingRankingScored("LAST_30D", otherAnchor, "control", 3L, 30, 0, 0, 3.0)); + + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf(targetAnchor)); + + assertAll( + () -> assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(aggregationRepository.countByPeriodKey(targetAnchor)).isZero(), + () -> assertThat(scoredRepository.countByPeriodKey(targetAnchor)).isZero(), + () -> assertThat(aggregationRepository.countByPeriodKey(otherAnchor)).isOne(), + () -> assertThat(scoredRepository.countByPeriodKey(otherAnchor)).isOne() + ); + } + } + + @Nested + class 멱등성 { + + @Test + void 비어있는_스테이징에_실행해도_멱등하게_성공한다() throws Exception { + jobLauncherTestUtils.setJob(job); + JobExecution execution = jobLauncherTestUtils.launchJob(paramsOf("20260414")); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + } + + @Test + void 같은_anchorDate_로_두번_돌려도_결과가_동일하다() throws Exception { + String anchor = "20260414"; + aggregationRepository.save(new StagingRankingAggregation("LAST_7D", anchor, 1L, 10, 0, 0)); + scoredRepository.save(new StagingRankingScored("LAST_7D", anchor, "control", 1L, 10, 0, 0, 1.0)); + + jobLauncherTestUtils.setJob(job); + + JobExecution first = jobLauncherTestUtils.launchJob(paramsOf(anchor)); + // 재실행을 위해 새 JobInstance 로 실행 (runTimestamp 로 격리) + JobExecution second = jobLauncherTestUtils.launchJob(paramsOf(anchor)); + + assertAll( + () -> assertThat(first.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(second.getStatus()).isEqualTo(BatchStatus.COMPLETED), + () -> assertThat(aggregationRepository.countByPeriodKey(anchor)).isZero(), + () -> assertThat(scoredRepository.countByPeriodKey(anchor)).isZero() + ); + } + } + + @Nested + class 파라미터_검증 { + + @Test + void anchorDate_파라미터가_없으면_Job_이_실패한다() throws Exception { + jobLauncherTestUtils.setJob(job); + + JobExecution execution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters() + ); + + assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED); + } + } + + private JobParameters paramsOf(String anchorDate) { + return new JobParametersBuilder() + .addString(RankingJobParametersListener.PARAM_ANCHOR_DATE, anchorDate) + .addLong("runTimestamp", System.nanoTime()) + .toJobParameters(); + } +} diff --git a/scripts/measure-ranking-batch.sh b/scripts/measure-ranking-batch.sh new file mode 100755 index 0000000000..9d7a795ce3 --- /dev/null +++ b/scripts/measure-ranking-batch.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# 랭킹 배치 선형성·스파이크 측정 스크립트. +# +# 설계.md Phase 5 (#26~29) 의 측정을 자동화한다: +# - S/M/L 단계별 실행 시간 → 선형성 검증 (입력 N배 → 시간 N배) +# - XL_SPIKE 단계 → worst-case SLA 확인 +# +# 사용법: +# ./scripts/measure-ranking-batch.sh +# +# benchmark 테스트는 결과를 apps/commerce-batch/build/benchmark-results.txt 에 append. +# 본 스크립트는 그 파일을 읽어 표 형식으로 출력한다. +# 결과 정리는 사람이 week10/측정결과.md 에 직접 기록한다 (자동 생성 아님). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# gradle test 의 working dir 는 모듈 root 라 결과 파일은 모듈 build/ 아래에 생성됨 +OUT_FILE="apps/commerce-batch/build/benchmark-results.txt" +rm -f "$OUT_FILE" + +echo "▶ benchmarkTest 실행 중 (수 분 ~ 수십 분 소요 가능)..." +./gradlew :apps:commerce-batch:benchmarkTest --console=plain --rerun-tasks + +if [[ ! -f "$OUT_FILE" ]]; then + echo "❌ 결과 파일이 생성되지 않았습니다: $OUT_FILE" + exit 1 +fi + +echo +echo "===================================================" +echo " 측정 결과 요약 (raw: $OUT_FILE)" +echo "===================================================" +printf "%-10s %-10s %-10s %-10s %-10s %-10s %-12s\n" \ + "label" "products" "active" "seedRows" "seedMs" "jobMs" "tps(rows/s)" +echo "---------------------------------------------------" + +while read -r line; do + [[ "$line" =~ ^BENCH\| ]] || continue + label=$(echo "$line" | sed -n 's/.*label=\([^ ]*\).*/\1/p') + products=$(echo "$line" | sed -n 's/.*totalProducts=\([^ ]*\).*/\1/p') + active=$(echo "$line" | sed -n 's/.*activeProducts=\([^ ]*\).*/\1/p') + seedRows=$(echo "$line" | sed -n 's/.*seedRows=\([^ ]*\).*/\1/p') + seedMs=$(echo "$line" | sed -n 's/.*seedMs=\([^ ]*\).*/\1/p') + jobMs=$(echo "$line" | sed -n 's/.*jobMs=\([^ ]*\).*/\1/p') + tps=$(echo "$line" | sed -n 's/.*tpsRowsPerSec=\([^ ]*\).*/\1/p') + printf "%-10s %-10s %-10s %-10s %-10s %-10s %-12s\n" \ + "$label" "$products" "$active" "$seedRows" "$seedMs" "$jobMs" "$tps" +done < "$OUT_FILE" + +echo +echo "▶ 결과를 week10/측정결과.md 에 정리해 기록하세요." diff --git a/week10/blog-final-final.md b/week10/blog-final-final.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/week10/blog-final.md b/week10/blog-final.md new file mode 100644 index 0000000000..e69de29bb2