From 79bbc12e9265908444c83d0b1e5a976e9a4f004c Mon Sep 17 00:00:00 2001 From: Avocado Date: Thu, 16 Apr 2026 21:17:43 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=ED=82=A4=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingPeriodKey 클래스를 새로 추가하여 주간 및 월간 랭킹 기간 키를 생성하는 기능을 구현. - 주간 키는 ISO-8601 주 규칙에 따라 월요일을 앵커로 사용하며, 월간 키는 해당 월의 첫째 날을 사용. - RankingPeriodKeyTest 클래스를 추가하여 주간 및 월간 키 생성 로직에 대한 테스트 추가 --- .../batch/ranking/RankingPeriodKey.java | 55 +++++++++++++++++++ .../batch/ranking/RankingPeriodKeyTest.java | 51 +++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java new file mode 100644 index 000000000..7dbd16c1a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/RankingPeriodKey.java @@ -0,0 +1,55 @@ +package com.loopers.batch.ranking; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 주간/월간 랭킹 집계에 사용하는 기간 키를 생성한다. + *

+ * 이전 라운드와 동일하게 {@code yyyyMMdd} 형식을 사용하며, + * 주간/월간은 각각 대표 일자(앵커 날짜)를 {@code yyyyMMdd}로 표현한다. + */ +public final class RankingPeriodKey { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private RankingPeriodKey() { + } + + /** + * 주간 랭킹 기간 키를 생성한다. + *

+ * ISO-8601 week 규칙으로 같은 주에 속하는 날짜들은 모두 동일한 대표 일자(예: 해당 주의 월요일)로 매핑되고, + * 반환 형식은 {@code yyyyMMdd}이다. + * + * @param date 기간에 속한 임의의 날짜 (Asia/Seoul 달력 기준으로 계산된 일자) + * @return 예: {@code 20260406} (2026년 4월 6일이 속한 주의 대표 일자) + * @throws IllegalArgumentException date가 null인 경우 + */ + public static String weekly(LocalDate date) { + if (date == null) { + throw new IllegalArgumentException("date must not be null"); + } + // ISO 기준으로 같은 주에 속하는 날짜들이 모두 같은 월요일 앵커로 귀속되도록 한다. + LocalDate anchor = date.with(java.time.DayOfWeek.MONDAY); + return DATE.format(anchor); + } + + /** + * 월간 랭킹 기간 키를 생성한다. + *

+ * 반환 형식은 {@code yyyyMMdd}이며, 해당 월의 첫째 날을 대표 일자로 사용한다. + * + * @param date 기간에 속한 임의의 날짜 (Asia/Seoul 달력 기준으로 계산된 일자) + * @return 예: {@code 20260401} + * @throws IllegalArgumentException date가 null인 경우 + */ + public static String monthly(LocalDate date) { + if (date == null) { + throw new IllegalArgumentException("date must not be null"); + } + LocalDate firstDayOfMonth = date.withDayOfMonth(1); + return DATE.format(firstDayOfMonth); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java new file mode 100644 index 000000000..21dce36c9 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java @@ -0,0 +1,51 @@ +package com.loopers.batch.ranking; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingPeriodKeyTest { + + @Test + @DisplayName("weekly: 같은 ISO 주에 속한 날짜들은 동일한 yyyyMMdd(월요일 앵커) 키를 가진다.") + void weekly_whenDatesInSameIsoWeek_shouldShareMondayAnchor() { + // given + LocalDate wednesday = LocalDate.of(2026, 4, 8); // 수요일 + LocalDate friday = LocalDate.of(2026, 4, 10); // 같은 주 금요일 + + // when + String key1 = RankingPeriodKey.weekly(wednesday); + String key2 = RankingPeriodKey.weekly(friday); + + // then + assertThat(key1).isEqualTo("20260406"); // 해당 주 월요일 + assertThat(key2).isEqualTo("20260406"); + } + + @Test + @DisplayName("monthly: yyyyMMdd 형식으로 해당 월의 첫째 날 키를 만든다.") + void monthly_whenLocalDateGiven_shouldReturnFirstDayYyyyMmDd() { + // given + LocalDate date = LocalDate.of(2026, 4, 16); + + // when + String key = RankingPeriodKey.monthly(date); + + // then + assertThat(key).isEqualTo("20260401"); + } + + @Test + @DisplayName("null을 넘기면 IllegalArgumentException을 던진다.") + void weeklyAndMonthly_whenNull_shouldThrow() { + assertThatThrownBy(() -> RankingPeriodKey.weekly(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RankingPeriodKey.monthly(null)) + .isInstanceOf(IllegalArgumentException.class); + } +} + From 9b3aa020461fb5a9aa0057825c0082f37f16516f Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 08:55:57 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=20MV=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductRankMonthlyRepository 및 ProductRankWeeklyRepository 인터페이스를 추가하여 주간 및 월간 랭킹 데이터를 저장하고 조회하는 기능을 정의. - MvProductRankMonthlyEntity 및 MvProductRankWeeklyEntity 클래스를 추가하여 JPA를 통한 데이터베이스 엔티티를 구현. - ProductRankMonthlyRepositoryImpl 및 ProductRankWeeklyRepositoryImpl 클래스를 추가하여 저장소 인터페이스의 구현체를 제공. - 데이터베이스 마이그레이션 스크립트를 추가하여 주간 및 월간 랭킹 테이블을 생성. - 관련 테스트 케이스를 추가하여 저장 및 조회 기능의 동작을 검증. --- .../mv/ProductRankMonthlyRepository.java | 13 +++ .../domain/ranking/mv/ProductRankMvRow.java | 40 +++++++ .../mv/ProductRankWeeklyRepository.java | 13 +++ .../mv/MvProductRankMonthlyEntity.java | 61 ++++++++++ .../mv/MvProductRankMonthlyJpaRepository.java | 10 ++ .../ranking/mv/MvProductRankWeeklyEntity.java | 61 ++++++++++ .../mv/MvProductRankWeeklyJpaRepository.java | 10 ++ .../mv/ProductRankMonthlyRepositoryImpl.java | 53 +++++++++ .../mv/ProductRankWeeklyRepositoryImpl.java | 53 +++++++++ .../src/main/resources/application.yml | 14 +++ .../V1__mv_product_rank_weekly_monthly.sql | 26 +++++ ...roductRankMvRepositoryIntegrationTest.java | 110 ++++++++++++++++++ 12 files changed, 464 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java new file mode 100644 index 000000000..958d8a9b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMonthlyRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking.mv; + +import java.util.List; + +/** + * {@code mv_product_rank_monthly} 접근 포트. 구현체는 infrastructure 레이어에 둔다. + */ +public interface ProductRankMonthlyRepository { + + void save(ProductRankMvRow row); + + List findByPeriodKeyOrderByRankAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java new file mode 100644 index 000000000..2914aaa43 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvRow.java @@ -0,0 +1,40 @@ +package com.loopers.domain.ranking.mv; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Optional; + +/** + * 주간/월간 랭킹 MV 조회·적재용 불변 행. + */ +public record ProductRankMvRow( + Optional id, + String periodKey, + long productId, + int rank, + BigDecimal score, + int version, + Instant updatedAt +) { + + /** + * 새로운 행을 생성한다. + * + * @param periodKey 기간 키 + * @param productId 상품 ID + * @param rank 랭킹 + * @param score 랭킹 점수 + * @param version 버전 + * @param updatedAt 업데이트 시간 + */ + public static ProductRankMvRow newRow( + String periodKey, + long productId, + int rank, + BigDecimal score, + int version, + Instant updatedAt + ) { + return new ProductRankMvRow(Optional.empty(), periodKey, productId, rank, score, version, updatedAt); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java new file mode 100644 index 000000000..40a6a8715 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankWeeklyRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking.mv; + +import java.util.List; + +/** + * {@code mv_product_rank_weekly} 접근 포트. 구현체는 infrastructure 레이어에 둔다. + */ +public interface ProductRankWeeklyRepository { + + void save(ProductRankMvRow row); + + List findByPeriodKeyOrderByRankAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java new file mode 100644 index 000000000..fcf7eaf48 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +/** + * 월간 랭킹 MV 엔티티. + * UNIQUE(period_key, product_id) 위반 시 저장이 실패한다. + * period_key: yyyyMMdd, product_id: product.id, rank: int, score: decimal(24,8), version: int, updated_at: timestamp. + * + * 낙관적 락: @Version 필드로 동시 사용 시 커밋 단계에서 충돌 감지·롤백. + * JPA는 UPDATE 시 {@code WHERE id = ? AND version = ?}를 사용하며, 버전 불일치 시 수정 행 0 → OptimisticLockException. (05-transaction-query §3.1) + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_mv_product_rank_monthly_period_product", + columnNames = {"period_key", "product_id"} + ) +) +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class MvProductRankMonthlyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_key", nullable = false, length = 16) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rankValue; + + @Column(name = "score", nullable = false, precision = 24, scale = 8) + private BigDecimal score; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..60dcf82d3 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking.mv; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankValueAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java new file mode 100644 index 000000000..287cd8e55 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +/** + * 주간 랭킹 MV 엔티티. + * UNIQUE(period_key, product_id) 위반 시 저장이 실패한다. + * period_key: yyyyMMdd, product_id: product.id, rank: int, score: decimal(24,8), version: int, updated_at: timestamp. + * + * @Version 필드로 동시 사용 시 커밋 단계에서 충돌 감지·롤백. + * JPA는 UPDATE 시 {@code WHERE id = ? AND version = ?}를 사용하며, 버전 불일치 시 수정 행 0 → OptimisticLockException. (05-transaction-query §3.1) + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_mv_product_rank_weekly_period_product", + columnNames = {"period_key", "product_id"} + ) +) +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class MvProductRankWeeklyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_key", nullable = false, length = 16) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rankValue; + + @Column(name = "score", nullable = false, precision = 24, scale = 8) + private BigDecimal score; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..e5f83456e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking.mv; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankValueAsc(String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java new file mode 100644 index 000000000..4c1d0bce2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMonthlyRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.ProductRankMonthlyRepository; +import com.loopers.domain.ranking.mv.ProductRankMvRow; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class ProductRankMonthlyRepositoryImpl implements ProductRankMonthlyRepository { + + private final MvProductRankMonthlyJpaRepository jpaRepository; + + public ProductRankMonthlyRepositoryImpl(MvProductRankMonthlyJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public void save(ProductRankMvRow row) { + jpaRepository.save(toEntity(row)); + } + + @Override + public List findByPeriodKeyOrderByRankAsc(String periodKey) { + return jpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + .map(ProductRankMonthlyRepositoryImpl::toRow) + .toList(); + } + + private static MvProductRankMonthlyEntity toEntity(ProductRankMvRow row) { + MvProductRankMonthlyEntity entity = new MvProductRankMonthlyEntity(); + entity.setPeriodKey(row.periodKey()); + entity.setProductId(row.productId()); + entity.setRankValue(row.rank()); + entity.setScore(row.score()); + entity.setVersion(row.version()); + entity.setUpdatedAt(row.updatedAt()); + return entity; + } + + private static ProductRankMvRow toRow(MvProductRankMonthlyEntity entity) { + return new ProductRankMvRow( + Optional.ofNullable(entity.getId()), + entity.getPeriodKey(), + entity.getProductId(), + entity.getRankValue(), + entity.getScore(), + entity.getVersion(), + entity.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java new file mode 100644 index 000000000..0d2551286 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankWeeklyRepositoryImpl.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.ProductRankMvRow; +import com.loopers.domain.ranking.mv.ProductRankWeeklyRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class ProductRankWeeklyRepositoryImpl implements ProductRankWeeklyRepository { + + private final MvProductRankWeeklyJpaRepository jpaRepository; + + public ProductRankWeeklyRepositoryImpl(MvProductRankWeeklyJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public void save(ProductRankMvRow row) { + jpaRepository.save(toEntity(row)); + } + + @Override + public List findByPeriodKeyOrderByRankAsc(String periodKey) { + return jpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + .map(ProductRankWeeklyRepositoryImpl::toRow) + .toList(); + } + + private static MvProductRankWeeklyEntity toEntity(ProductRankMvRow row) { + MvProductRankWeeklyEntity entity = new MvProductRankWeeklyEntity(); + entity.setPeriodKey(row.periodKey()); + entity.setProductId(row.productId()); + entity.setRankValue(row.rank()); + entity.setScore(row.score()); + entity.setVersion(row.version()); + entity.setUpdatedAt(row.updatedAt()); + return entity; + } + + private static ProductRankMvRow toRow(MvProductRankWeeklyEntity entity) { + return new ProductRankMvRow( + Optional.ofNullable(entity.getId()), + entity.getPeriodKey(), + entity.getProductId(), + entity.getRankValue(), + entity.getScore(), + entity.getVersion(), + entity.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml index ba5fa9fd0..8957de989 100644 --- a/apps/commerce-batch/src/main/resources/application.yml +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -3,6 +3,9 @@ spring: web-application-type: none application: name: commerce-batch + # 운영에서 MV 스키마는 classpath:db/migration (Flyway). local/test는 Hibernate create + Flyway 비활성. + flyway: + enabled: false profiles: active: local config: @@ -64,6 +67,17 @@ outbox: cleanup: enabled: false +--- +spring: + config: + activate: + on-profile: dev, qa, prd + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + table: flyway_schema_history_commerce_batch + --- spring: config: diff --git a/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql b/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql new file mode 100644 index 000000000..13de88dc3 --- /dev/null +++ b/apps/commerce-batch/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql @@ -0,0 +1,26 @@ +-- Round 10: 주간/월간 랭킹 MV (운영 스키마는 Flyway로만 변경; JPA 엔티티와 동일 스키마 유지) +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT NOT NULL AUTO_INCREMENT, + period_key VARCHAR(16) NOT NULL, + product_id BIGINT NOT NULL, + `rank` INT NOT NULL, + score DECIMAL(24, 8) NOT NULL, + version INT NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mv_product_rank_weekly_period_product (period_key, product_id), + KEY idx_mv_product_rank_weekly_period_rank (period_key, `rank`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT, + period_key VARCHAR(16) NOT NULL, + product_id BIGINT NOT NULL, + `rank` INT NOT NULL, + score DECIMAL(24, 8) NOT NULL, + version INT NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mv_product_rank_monthly_period_product (period_key, product_id), + KEY idx_mv_product_rank_monthly_period_rank (period_key, `rank`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java new file mode 100644 index 000000000..0ba509f34 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvRepositoryIntegrationTest.java @@ -0,0 +1,110 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.ProductRankMonthlyRepository; +import com.loopers.domain.ranking.mv.ProductRankMvRow; +import com.loopers.domain.ranking.mv.ProductRankWeeklyRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@Import(MySqlTestContainersConfig.class) +class ProductRankMvRepositoryIntegrationTest { + + @Autowired + private ProductRankWeeklyRepository weeklyRepository; + + @Autowired + private ProductRankMonthlyRepository monthlyRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주간 MV: 동일 period에서 rank 오름차순으로 조회한다.") + @Transactional + void weekly_findByPeriodKeyOrderByRankAsc_samePeriod_returnsOrdered() { + // given + String periodKey = "2026W15"; + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 200L, 2, new BigDecimal("12.5"), 1, at)); + weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 100L, 1, new BigDecimal("99.0"), 1, at)); + + // when + List rows = weeklyRepository.findByPeriodKeyOrderByRankAsc(periodKey); + + // then + assertThat(rows).hasSize(2); + assertThat(rows.get(0).productId()).isEqualTo(100L); + assertThat(rows.get(0).rank()).isEqualTo(1); + assertThat(rows.get(1).productId()).isEqualTo(200L); + assertThat(rows.get(1).rank()).isEqualTo(2); + } + + @Test + @DisplayName("월간 MV: 동일 period에서 rank 오름차순으로 조회한다.") + @Transactional + void monthly_findByPeriodKeyOrderByRankAsc_samePeriod_returnsOrdered() { + // given + String periodKey = "202604"; + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 20L, 2, new BigDecimal("3.25"), 1, at)); + monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 10L, 1, new BigDecimal("8.00"), 1, at)); + + // when + List rows = monthlyRepository.findByPeriodKeyOrderByRankAsc(periodKey); + + // then + assertThat(rows).hasSize(2); + assertThat(rows.get(0).productId()).isEqualTo(10L); + assertThat(rows.get(1).productId()).isEqualTo(20L); + } + + @Test + @DisplayName("주간 MV: UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.") + void weekly_save_duplicatePeriodAndProduct_shouldFail() { + // given + String periodKey = "2026W16"; + Instant at = Instant.parse("2026-05-01T00:00:00Z"); + weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.ONE, 1, at)); + + // when // then + assertThatThrownBy(() -> weeklyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 2, BigDecimal.TEN, 1, at))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("월간 MV: UNIQUE(period_key, product_id) 위반 시 저장이 실패한다.") + void monthly_save_duplicatePeriodAndProduct_shouldFail() { + // given + String periodKey = "202605"; + Instant at = Instant.parse("2026-05-01T00:00:00Z"); + monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.ONE, 1, at)); + + // when // then + assertThatThrownBy(() -> monthlyRepository.save(ProductRankMvRow.newRow(periodKey, 1L, 2, BigDecimal.TEN, 1, at))) + .isInstanceOf(DataIntegrityViolationException.class); + } +} From 2d636a1afabb2ff7ea4840415f1f633e1695386d Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 09:49:56 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=20=EA=B5=AC=EC=84=B1=20=EB=B0=8F?= =?UTF-8?q?=20Redis=20=EB=9D=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingBatchJobConfig 클래스를 추가하여 랭킹 배치 작업의 구성 및 단계 정의. - RankingBatchJobParameters 클래스를 추가하여 배치 작업의 파라미터 검증 및 락 키 규칙을 정의. - RedisRankingBatchLock 클래스를 추가하여 Redis 기반의 락 획득 및 해제 기능 구현. - 관련 테스트 케이스를 추가하여 배치 작업의 파라미터 검증 및 락 기능의 동작을 검증. - build.gradle.kts 파일에 Flyway 의존성을 추가하여 데이터베이스 마이그레이션 지원. --- apps/commerce-batch/build.gradle.kts | 2 + .../ranking/job/RankingBatchJobConfig.java | 295 ++++++++++++++++++ .../batch/RankingBatchJobParameters.java | 96 ++++++ .../ranking/batch/RedisRankingBatchLock.java | 59 ++++ .../batch/RankingBatchJobParametersTest.java | 54 ++++ .../job/ranking/RankingBatchJobE2ETest.java | 81 +++++ .../RankingBatchJobLockIntegrationTest.java | 76 +++++ 7 files changed, 663 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts index 205bedfbe..0a5b4ffad 100644 --- a/apps/commerce-batch/build.gradle.kts +++ b/apps/commerce-batch/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { // batch implementation("org.springframework.boot:spring-boot-starter-batch") + runtimeOnly("org.flywaydb:flyway-core") + runtimeOnly("org.flywaydb:flyway-mysql") testImplementation("org.springframework.batch:spring-batch-test") testImplementation("org.springframework.kafka:spring-kafka-test") diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java new file mode 100644 index 000000000..fdd411001 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java @@ -0,0 +1,295 @@ +package com.loopers.batch.ranking.job; + +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.infrastructure.ranking.batch.RedisRankingBatchLock; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.core.JobParametersValidator; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.UnexpectedJobExecutionException; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; + +/** + * Round 10 — 랭킹 MV 배치 Job(3단계): 파라미터 검증 → period 락 → staging 정리(자리) → 집계(자리) → publish(자리). + */ +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingBatchJobParameters.JOB_NAME) +@Configuration +public class RankingBatchJobConfig { + + public static final String JOB_NAME = RankingBatchJobParameters.JOB_NAME; + + private static final String STEP_PERIOD_LOCK = "rankingPeriodLock"; + private static final String STEP_STAGING_CLEANUP = "rankingStagingCleanup"; + private static final String STEP_AGGREGATE = "rankingAggregate"; + private static final String STEP_PUBLISH = "rankingPublish"; + + private final ObjectProvider jobRepositoryProvider; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final PlatformTransactionManager transactionManager; + + public RankingBatchJobConfig( + ObjectProvider jobRepositoryProvider, + JobListener jobListener, + StepMonitorListener stepMonitorListener, + PlatformTransactionManager transactionManager + ) { + this.jobRepositoryProvider = jobRepositoryProvider; + this.jobListener = jobListener; + this.stepMonitorListener = stepMonitorListener; + this.transactionManager = transactionManager; + } + + private JobRepository jobRepository() { + return jobRepositoryProvider.getObject(); + } + + /** + * 리소스 없는 트랜잭션 매니저를 생성한다. + * + * @return ResourcelessTransactionManager + */ + @Bean + public ResourcelessTransactionManager rankingBatchResourcelessTransactionManager() { + return new ResourcelessTransactionManager(); + } + + /** + * Redis 랭킹 배치 락을 생성한다. + * + * @param redisTemplate RedisTemplate + * @return RedisRankingBatchLock + */ + @Bean + public RedisRankingBatchLock rankingBatchLock( + @Qualifier("redisTemplateMaster") RedisTemplate redisTemplate + ) { + return new RedisRankingBatchLock(redisTemplate); + } + + /** + * 랭킹 배치 파라미터 검증을 생성한다. + * + * @return JobParametersValidator + */ + @Bean + public JobParametersValidator rankingJobParametersValidator() { + return new JobParametersValidator() { + @Override + public void validate(JobParameters parameters) throws JobParametersInvalidException { + String period = parameters.getString(RankingBatchJobParameters.JOB_PARAM_PERIOD); + String periodKey = parameters.getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY); + if (period == null || period.isBlank()) { + throw new JobParametersInvalidException( + "Job 파라미터 period는 필수입니다. (WEEKLY 또는 MONTHLY)" + ); + } + if (periodKey == null || periodKey.isBlank()) { + throw new JobParametersInvalidException("Job 파라미터 periodKey는 필수입니다."); + } + try { + RankingBatchJobParameters.validate(period, periodKey); + } catch (IllegalArgumentException e) { + throw new JobParametersInvalidException(e.getMessage()); + } + } + }; + } + + /** + * 랭킹 배치 락 해제를 생성한다. + * + * @param lock RedisRankingBatchLock + * @return JobExecutionListener + */ + @Bean + public JobExecutionListener rankingBatchLockReleaseListener(RedisRankingBatchLock lock) { + return new JobExecutionListener() { + @Override + public void beforeJob(JobExecution jobExecution) { + } + + @Override + public void afterJob(JobExecution jobExecution) { + var ctx = jobExecution.getExecutionContext(); + if (!"true".equals(ctx.getString(RankingBatchJobParameters.CTX_LOCK_HELD))) { + return; + } + String period = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD); + String periodKey = jobExecution.getJobParameters() + .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY); + String owner = ctx.getString(RankingBatchJobParameters.CTX_LOCK_OWNER); + if (period != null && periodKey != null && owner != null) { + lock.releaseIfHeld(period, periodKey, owner); + } + ctx.remove(RankingBatchJobParameters.CTX_LOCK_HELD); + ctx.remove(RankingBatchJobParameters.CTX_LOCK_OWNER); + } + }; + } + + /** + * 랭킹 배치 락 획득을 생성한다. + * + * @param rankingBatchLock RedisRankingBatchLock + * @param period 기간 + * @param periodKey 기간 키 + * @return Tasklet + */ + @Bean + @StepScope + public Tasklet rankingPeriodLockTasklet( + RedisRankingBatchLock rankingBatchLock, + @Value("#{jobParameters['period']}") String period, + @Value("#{jobParameters['periodKey']}") String periodKey + ) { + return (contribution, chunkContext) -> { + long jobExecutionId = contribution.getStepExecution().getJobExecution().getId(); + String ownerToken = String.valueOf(jobExecutionId); + if (!rankingBatchLock.tryAcquire(period, periodKey, ownerToken)) { + throw new UnexpectedJobExecutionException( + "period 락을 획득하지 못했습니다. 다른 실행이 동일 period를 처리 중일 수 있습니다: " + + period + ":" + periodKey + ); + } + var jobCtx = contribution.getStepExecution().getJobExecution().getExecutionContext(); + jobCtx.putString(RankingBatchJobParameters.CTX_LOCK_HELD, "true"); + jobCtx.putString(RankingBatchJobParameters.CTX_LOCK_OWNER, ownerToken); + return RepeatStatus.FINISHED; + }; + } + + /** + * 랭킹 배치 Job을 생성한다. + * + * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager + * @param rankingJobParametersValidator JobParametersValidator + * @param rankingBatchLockReleaseListener JobExecutionListener + * @param periodLockStep Step + * @param stagingCleanupStep Step + * @param aggregateStep Step + * @param publishStep Step + * @return Job + */ + @Bean(JOB_NAME) + public Job rankingProductMvJob( + ResourcelessTransactionManager rankingBatchResourcelessTransactionManager, + JobParametersValidator rankingJobParametersValidator, + JobExecutionListener rankingBatchLockReleaseListener, + @Qualifier(STEP_PERIOD_LOCK) Step periodLockStep, + @Qualifier(STEP_STAGING_CLEANUP) Step stagingCleanupStep, + @Qualifier(STEP_AGGREGATE) Step aggregateStep, + @Qualifier(STEP_PUBLISH) Step publishStep + ) { + return new JobBuilder(JOB_NAME, jobRepository()) + .incrementer(new RunIdIncrementer()) + .validator(rankingJobParametersValidator) + .listener(rankingBatchLockReleaseListener) + .listener(jobListener) + .start(periodLockStep) + .next(stagingCleanupStep) + .next(aggregateStep) + .next(publishStep) + .build(); + } + + /** + * 랭킹 배치 락 획득을 생성한다. + * + * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager + * @param rankingPeriodLockTasklet Tasklet + * @return Step + */ + @Bean(STEP_PERIOD_LOCK) + public Step periodLockStep( + ResourcelessTransactionManager rankingBatchResourcelessTransactionManager, + Tasklet rankingPeriodLockTasklet + ) { + return new StepBuilder(STEP_PERIOD_LOCK, jobRepository()) + .tasklet(rankingPeriodLockTasklet, rankingBatchResourcelessTransactionManager) + .listener(stepMonitorListener) + .build(); + } + + /** + * 랭킹 배치 스테이징 정리를 생성한다. + * + * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager + * @return Step + */ + @Bean(STEP_STAGING_CLEANUP) + public Step stagingCleanupStep(ResourcelessTransactionManager rankingBatchResourcelessTransactionManager) { + Tasklet noop = (contribution, chunkContext) -> RepeatStatus.FINISHED; + return new StepBuilder(STEP_STAGING_CLEANUP, jobRepository()) + .tasklet(noop, rankingBatchResourcelessTransactionManager) + .listener(stepMonitorListener) + .build(); + } + + /** + * 랭킹 배치 집계를 생성한다. + * + * @param rankingAggregateEmptyReader ListItemReader + * @return Step + */ + @Bean(STEP_AGGREGATE) + public Step aggregateStep(ListItemReader rankingAggregateEmptyReader) { + return new StepBuilder(STEP_AGGREGATE, jobRepository()) + .chunk(10, transactionManager) + .reader(rankingAggregateEmptyReader) + .processor(item -> item) + .writer(chunk -> { + }) + .listener(stepMonitorListener) + .build(); + } + + /** + * 랭킹 배치 발행을 생성한다. + * + * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager + * @return Step + */ + @Bean(STEP_PUBLISH) + public Step publishStep(ResourcelessTransactionManager rankingBatchResourcelessTransactionManager) { + Tasklet noop = (contribution, chunkContext) -> RepeatStatus.FINISHED; + return new StepBuilder(STEP_PUBLISH, jobRepository()) + .tasklet(noop, rankingBatchResourcelessTransactionManager) + .listener(stepMonitorListener) + .build(); + } + + /** + * 랭킹 배치 집계 빈 리더를 생성한다. + * + * @return ListItemReader + */ + @StepScope + @Bean + public ListItemReader rankingAggregateEmptyReader() { + return new ListItemReader<>(List.of()); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java new file mode 100644 index 000000000..78fbe8d55 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingBatchJobParameters.java @@ -0,0 +1,96 @@ +package com.loopers.domain.ranking.batch; + +import java.util.regex.Pattern; + +/** + * 랭킹 MV 배치 Job 파라미터 이름·값 검증·락 키 규칙을 한곳에 둔다. + */ +public final class RankingBatchJobParameters { + + public static final String JOB_NAME = "rankingProductMvJob"; + public static final String JOB_PARAM_PERIOD = "period"; + public static final String JOB_PARAM_PERIOD_KEY = "periodKey"; + + public static final String CTX_LOCK_HELD = "ranking.batch.lockHeld"; + public static final String CTX_LOCK_OWNER = "ranking.batch.lockOwnerToken"; + + private static final Pattern WEEKLY_KEY = Pattern.compile("^\\d{4}W\\d{2}$"); + private static final Pattern MONTHLY_KEY = Pattern.compile("^\\d{6}$"); + + private RankingBatchJobParameters() { + } + + /** + * 기간 타입. + */ + public enum Period { + WEEKLY, + MONTHLY; + + public static Period parse(String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("period는 필수이며 비어 있으면 안 됩니다."); + } + return Period.valueOf(raw.trim()); + } + } + + /** + * JobParameters 검증과 동일한 규칙으로 period·periodKey를 검사한다. + */ + public static void validate(String periodRaw, String periodKeyRaw) { + if (periodKeyRaw == null || periodKeyRaw.isBlank()) { + throw new IllegalArgumentException("periodKey는 필수이며 비어 있으면 안 됩니다."); + } + Period period = Period.parse(periodRaw); + switch (period) { + case WEEKLY -> validateWeeklyKey(periodKeyRaw); + case MONTHLY -> validateMonthlyKey(periodKeyRaw); + } + } + + /** + * Redis 락 키를 생성한다. + * + * @param period 기간 + * @param periodKey 기간 키 + * @return Redis 락 키 + */ + public static String redisLockKey(String period, String periodKey) { + return "batch:rank:lock:" + period + ":" + periodKey; + } + + /** + * 주간 키를 검증한다. + * + * @param periodKey 기간 키 + */ + private static void validateWeeklyKey(String periodKey) { + if (!WEEKLY_KEY.matcher(periodKey).matches()) { + throw new IllegalArgumentException( + "period가 WEEKLY일 때 periodKey는 yyyyWww 형식이어야 합니다. 예: 2026W15" + ); + } + int week = Integer.parseInt(periodKey.substring(periodKey.indexOf('W') + 1)); + if (week < 1 || week > 53) { + throw new IllegalArgumentException("주차는 01~53 범위여야 합니다."); + } + } + + /** + * 월간 키를 검증한다. + * + * @param periodKey 기간 키 + */ + private static void validateMonthlyKey(String periodKey) { + if (!MONTHLY_KEY.matcher(periodKey).matches()) { + throw new IllegalArgumentException( + "period가 MONTHLY일 때 periodKey는 yyyyMM 형식이어야 합니다. 예: 202604" + ); + } + int month = Integer.parseInt(periodKey.substring(4, 6)); + if (month < 1 || month > 12) { + throw new IllegalArgumentException("월은 01~12 범위여야 합니다."); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java new file mode 100644 index 000000000..8e0d54ea0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RedisRankingBatchLock.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.ranking.batch; + +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.Duration; +import java.util.Objects; + +/** + * Redis SET NX 기반 period 락. Job 설정 클래스에서만 Bean으로 등록한다. + */ +public final class RedisRankingBatchLock { + + private static final Duration LOCK_TTL = Duration.ofHours(4); + + private final RedisTemplate redisTemplate; + + /** + * 생성자 주입. + * + * @param redisTemplate RedisTemplate + */ + public RedisRankingBatchLock(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 락을 획득한다. + * + * @param period 기간 + * @param periodKey 기간 키 + * @param ownerToken 소유자 토큰 + * @return 락 획득 여부 + */ + public boolean tryAcquire(String period, String periodKey, String ownerToken) { + Objects.requireNonNull(ownerToken, "ownerToken"); + String key = RankingBatchJobParameters.redisLockKey(period, periodKey); + Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, ownerToken, LOCK_TTL); + return Boolean.TRUE.equals(ok); + } + + /** + * 락을 해제한다. + * + * @param period 기간 + * @param periodKey 기간 키 + * @param ownerToken 소유자 토큰 + */ + public void releaseIfHeld(String period, String periodKey, String ownerToken) { + if (ownerToken == null) { + return; + } + String key = RankingBatchJobParameters.redisLockKey(period, periodKey); + String current = redisTemplate.opsForValue().get(key); + if (ownerToken.equals(current)) { + redisTemplate.delete(key); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java new file mode 100644 index 000000000..60d447bd4 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingBatchJobParametersTest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.ranking.batch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingBatchJobParametersTest { + + @Test + @DisplayName("WEEKLY + yyyyWww 형식이면 통과한다.") + void validate_whenWeeklyOk_shouldPass() { + assertThatCode(() -> RankingBatchJobParameters.validate("WEEKLY", "2026W15")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("WEEKLY인데 주차 범위 밖이면 실패한다.") + void validate_whenWeeklyWeekOutOfRange_shouldThrow() { + assertThatThrownBy(() -> RankingBatchJobParameters.validate("WEEKLY", "2026W54")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주차"); + } + + @Test + @DisplayName("MONTHLY + yyyyMM 형식이면 통과한다.") + void validate_whenMonthlyOk_shouldPass() { + assertThatCode(() -> RankingBatchJobParameters.validate("MONTHLY", "202604")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("MONTHLY인데 월이 무효이면 실패한다.") + void validate_whenMonthlyInvalidMonth_shouldThrow() { + assertThatThrownBy(() -> RankingBatchJobParameters.validate("MONTHLY", "202613")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("월"); + } + + @Test + @DisplayName("period가 잘못되면 실패한다.") + void validate_whenPeriodInvalid_shouldThrow() { + assertThatThrownBy(() -> RankingBatchJobParameters.validate("DAILY", "202604")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("periodKey가 비어 있으면 실패한다.") + void validate_whenPeriodKeyBlank_shouldThrow() { + assertThatThrownBy(() -> RankingBatchJobParameters.validate("WEEKLY", " ")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java new file mode 100644 index 000000000..3675ec065 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java @@ -0,0 +1,81 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.ranking.job.RankingBatchJobConfig; +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.JobParametersInvalidException; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME) +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +class RankingBatchJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankingBatchJobConfig.JOB_NAME) + private Job job; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @Test + @DisplayName("period 없이 실행하면 JobParameters 검증에서 실패한다.") + void launchJob_whenPeriodMissing_shouldFailValidation() { + jobLauncherTestUtils.setJob(job); + + assertThatThrownBy(() -> jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15") + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + )).isInstanceOf(JobParametersInvalidException.class); + } + + @Test + @DisplayName("유효한 period·periodKey면 Job이 COMPLETED 된다.") + void launchJob_whenValidParameters_shouldComplete() throws Exception { + jobLauncherTestUtils.setJob(job); + var execution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15") + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + ); + + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), + () -> assertThat(execution.getStepExecutions()).hasSize(4) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java new file mode 100644 index 000000000..ca3409ef5 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobLockIntegrationTest.java @@ -0,0 +1,76 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.ranking.job.RankingBatchJobConfig; +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME) +@Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) +class RankingBatchJobLockIntegrationTest { + + private static final String PERIOD = "WEEKLY"; + private static final String PERIOD_KEY = "2026W20"; + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(RankingBatchJobConfig.JOB_NAME) + private Job job; + + @Autowired + @Qualifier("redisTemplateMaster") + private RedisTemplate redisTemplate; + + @Autowired + private RedisCleanUp redisCleanUp; + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @Test + @DisplayName("동일 period 락이 이미 있으면 첫 Step에서 실패한다.") + void launchJob_whenLockAlreadyHeld_shouldFail() throws Exception { + String lockKey = RankingBatchJobParameters.redisLockKey(PERIOD, PERIOD_KEY); + redisTemplate.opsForValue().set(lockKey, "other-owner", Duration.ofMinutes(10)); + + jobLauncherTestUtils.setJob(job); + var execution = jobLauncherTestUtils.launchJob( + new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, PERIOD) + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, PERIOD_KEY) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + ); + + assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()); + assertThat(execution.getStepExecutions()).hasSize(1); + } +} From 98c475b3b36a70e51af0b1c12b04bea54401e37b Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 10:52:21 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=EC=97=90=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=8F=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingMvScoreCalculator, RankingScoreCandidate, RankingStagingRankRow, RankingStagingRepository, RankingTop100Accumulator 클래스를 추가하여 랭킹 배치 작업의 도메인 모델 및 저장소 인터페이스를 구현. - MvProductRankStagingEntity 및 관련 JPA 저장소를 추가하여 스테이징 테이블에 대한 데이터베이스 연동을 지원. - ProductMetricsEntity 및 ProductMetricsJpaRepository를 추가하여 배치 작업에서 사용할 상품 메트릭스 데이터를 처리. - 데이터베이스 마이그레이션 스크립트를 추가하여 스테이징 테이블을 생성. - 관련 테스트 케이스를 추가하여 도메인 모델 및 저장소의 기능을 검증. --- .../ranking/job/RankingBatchJobConfig.java | 195 +++++++++++++++--- .../batch/RankingMvScoreCalculator.java | 28 +++ .../ranking/batch/RankingScoreCandidate.java | 7 + .../ranking/batch/RankingStagingRankRow.java | 9 + .../batch/RankingStagingRepository.java | 13 ++ .../batch/RankingTop100Accumulator.java | 62 ++++++ .../batch/MvProductRankStagingEntity.java | 56 +++++ .../MvProductRankStagingJpaRepository.java | 12 ++ .../ranking/batch/ProductMetricsEntity.java | 68 ++++++ .../batch/ProductMetricsJpaRepository.java | 6 + .../batch/RankingStagingRepositoryImpl.java | 45 ++++ .../migration/V2__mv_product_rank_staging.sql | 14 ++ .../batch/RankingMvScoreCalculatorTest.java | 24 +++ .../batch/RankingTop100AccumulatorTest.java | 69 +++++++ ...nkingStagingRepositoryIntegrationTest.java | 61 ++++++ .../job/ranking/RankingBatchJobE2ETest.java | 41 +++- 16 files changed, 682 insertions(+), 28 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java index fdd411001..9dab8b226 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java @@ -3,7 +3,14 @@ import com.loopers.batch.listener.JobListener; import com.loopers.batch.listener.StepMonitorListener; import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.domain.ranking.batch.RankingMvScoreCalculator; +import com.loopers.domain.ranking.batch.RankingScoreCandidate; +import com.loopers.domain.ranking.batch.RankingStagingRepository; +import com.loopers.domain.ranking.batch.RankingTop100Accumulator; +import com.loopers.infrastructure.ranking.batch.ProductMetricsEntity; import com.loopers.infrastructure.ranking.batch.RedisRankingBatchLock; +import jakarta.persistence.EntityManagerFactory; +import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobExecution; import org.springframework.batch.core.JobExecutionListener; @@ -11,6 +18,8 @@ import org.springframework.batch.core.JobParametersInvalidException; import org.springframework.batch.core.JobParametersValidator; import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; import org.springframework.batch.core.UnexpectedJobExecutionException; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.job.builder.JobBuilder; @@ -18,20 +27,22 @@ import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.core.step.tasklet.Tasklet; -import org.springframework.batch.item.support.ListItemReader; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; import org.springframework.batch.repeat.RepeatStatus; import org.springframework.batch.support.transaction.ResourcelessTransactionManager; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.transaction.PlatformTransactionManager; -import java.util.List; - /** * Round 10 — 랭킹 MV 배치 Job(3단계): 파라미터 검증 → period 락 → staging 정리(자리) → 집계(자리) → publish(자리). */ @@ -55,7 +66,7 @@ public RankingBatchJobConfig( ObjectProvider jobRepositoryProvider, JobListener jobListener, StepMonitorListener stepMonitorListener, - PlatformTransactionManager transactionManager + @Lazy PlatformTransactionManager transactionManager ) { this.jobRepositoryProvider = jobRepositoryProvider; this.jobListener = jobListener; @@ -135,13 +146,15 @@ public void beforeJob(JobExecution jobExecution) { @Override public void afterJob(JobExecution jobExecution) { var ctx = jobExecution.getExecutionContext(); - if (!"true".equals(ctx.getString(RankingBatchJobParameters.CTX_LOCK_HELD))) { + Object lockHeld = ctx.get(RankingBatchJobParameters.CTX_LOCK_HELD); + if (!"true".equals(lockHeld instanceof String ? (String) lockHeld : null)) { return; } String period = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD); String periodKey = jobExecution.getJobParameters() .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY); - String owner = ctx.getString(RankingBatchJobParameters.CTX_LOCK_OWNER); + Object ownerObj = ctx.get(RankingBatchJobParameters.CTX_LOCK_OWNER); + String owner = ownerObj instanceof String ? (String) ownerObj : null; if (period != null && periodKey != null && owner != null) { lock.releaseIfHeld(period, periodKey, owner); } @@ -182,6 +195,135 @@ public Tasklet rankingPeriodLockTasklet( }; } + /** + * 랭킹 배치 스테이징 정리를 생성한다. + * + * @param rankingStagingRepository RankingStagingRepository + * @param period 기간 + * @param periodKey 기간 키 + * @return Tasklet + */ + @Bean + @StepScope + public Tasklet rankingStagingCleanupTasklet( + RankingStagingRepository rankingStagingRepository, + @Value("#{jobParameters['period']}") String period, + @Value("#{jobParameters['periodKey']}") String periodKey + ) { + return (contribution, chunkContext) -> { + rankingStagingRepository.deleteByPeriodTypeAndPeriodKey(period, periodKey); + return RepeatStatus.FINISHED; + }; + } + + /** + * 랭킹 배치 상품 메트릭스 리더를 생성한다. + * + * @param entityManagerFactory EntityManagerFactory + * @return JpaPagingItemReader + */ + @Bean + @StepScope + public JpaPagingItemReader rankingProductMetricsReader( + EntityManagerFactory entityManagerFactory + ) throws Exception { + JpaPagingItemReader reader = new JpaPagingItemReaderBuilder() + .name("rankingProductMetricsReader") + .entityManagerFactory(entityManagerFactory) + .pageSize(50) + .queryString("select e from ProductMetricsEntity e order by e.productId asc") + .build(); + reader.afterPropertiesSet(); + return reader; + } + + /** + * 랭킹 배치 상품 메트릭스 프로세서를 생성한다. + * + * @return ItemProcessor + */ + @Bean + @StepScope + public ItemProcessor rankingAggregateProcessor() { + return entity -> new RankingScoreCandidate( + entity.getProductId(), + RankingMvScoreCalculator.score( + entity.getViewCount(), + entity.getLikeCount(), + entity.getSoldQuantity() + ) + ); + } + + /** + * 랭킹 배치 집계 누적기를 생성한다. + * + * @return RankingTop100Accumulator + */ + @Bean + @StepScope + public RankingTop100Accumulator rankingTop100Accumulator() { + return new RankingTop100Accumulator(); + } + + /** + * 랭킹 배치 집계 히프 라이터를 생성한다. + * + * @param rankingTop100Accumulator RankingTop100Accumulator + * @return ItemWriter + */ + @Bean + @StepScope + public ItemWriter rankingAggregateHeapWriter( + RankingTop100Accumulator rankingTop100Accumulator + ) { + return chunk -> { + for (RankingScoreCandidate candidate : chunk.getItems()) { + rankingTop100Accumulator.accept(candidate); + } + }; + } + + /** + * 랭킹 배치 집계 플러시 리스너를 생성한다. + * + * @param rankingTop100Accumulator RankingTop100Accumulator + * @param rankingStagingRepository RankingStagingRepository + * @return StepExecutionListener + */ + @Bean + @StepScope + public StepExecutionListener rankingAggregateFlushListener( + RankingTop100Accumulator rankingTop100Accumulator, + RankingStagingRepository rankingStagingRepository + ) { + return new StepExecutionListener() { + @Override + public void beforeStep(StepExecution stepExecution) { + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + if (!ExitStatus.COMPLETED.equals(stepExecution.getExitStatus())) { + return stepExecution.getExitStatus(); + } + String periodType = stepExecution.getJobParameters() + .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD); + String periodKey = stepExecution.getJobParameters() + .getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY); + if (periodType == null || periodKey == null) { + return ExitStatus.FAILED; + } + rankingStagingRepository.saveRankedRows( + periodType, + periodKey, + rankingTop100Accumulator.toSortedRankRows() + ); + return ExitStatus.COMPLETED; + } + }; + } + /** * 랭킹 배치 Job을 생성한다. * @@ -241,10 +383,12 @@ public Step periodLockStep( * @return Step */ @Bean(STEP_STAGING_CLEANUP) - public Step stagingCleanupStep(ResourcelessTransactionManager rankingBatchResourcelessTransactionManager) { - Tasklet noop = (contribution, chunkContext) -> RepeatStatus.FINISHED; + public Step stagingCleanupStep( + PlatformTransactionManager transactionManager, + Tasklet rankingStagingCleanupTasklet + ) { return new StepBuilder(STEP_STAGING_CLEANUP, jobRepository()) - .tasklet(noop, rankingBatchResourcelessTransactionManager) + .tasklet(rankingStagingCleanupTasklet, transactionManager) .listener(stepMonitorListener) .build(); } @@ -252,17 +396,25 @@ public Step stagingCleanupStep(ResourcelessTransactionManager rankingBatchResour /** * 랭킹 배치 집계를 생성한다. * - * @param rankingAggregateEmptyReader ListItemReader + * @param rankingProductMetricsReader JpaPagingItemReader + * @param rankingAggregateProcessor ItemProcessor + * @param rankingAggregateHeapWriter ItemWriter + * @param rankingAggregateFlushListener StepExecutionListener * @return Step */ @Bean(STEP_AGGREGATE) - public Step aggregateStep(ListItemReader rankingAggregateEmptyReader) { + public Step aggregateStep( + JpaPagingItemReader rankingProductMetricsReader, + ItemProcessor rankingAggregateProcessor, + ItemWriter rankingAggregateHeapWriter, + StepExecutionListener rankingAggregateFlushListener + ) { return new StepBuilder(STEP_AGGREGATE, jobRepository()) - .chunk(10, transactionManager) - .reader(rankingAggregateEmptyReader) - .processor(item -> item) - .writer(chunk -> { - }) + .chunk(50, transactionManager) + .reader(rankingProductMetricsReader) + .processor(rankingAggregateProcessor) + .writer(rankingAggregateHeapWriter) + .listener(rankingAggregateFlushListener) .listener(stepMonitorListener) .build(); } @@ -281,15 +433,4 @@ public Step publishStep(ResourcelessTransactionManager rankingBatchResourcelessT .listener(stepMonitorListener) .build(); } - - /** - * 랭킹 배치 집계 빈 리더를 생성한다. - * - * @return ListItemReader - */ - @StepScope - @Bean - public ListItemReader rankingAggregateEmptyReader() { - return new ListItemReader<>(List.of()); - } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java new file mode 100644 index 000000000..c27c52276 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculator.java @@ -0,0 +1,28 @@ +package com.loopers.domain.ranking.batch; + +/** + * 스트리머 {@code RankingScoreCalculator}와 동일한 기본 가중치(0.1 / 0.2 / 0.6)로 점수를 계산한다. + * commerce-streamer 모듈에 의존하지 않기 위해 배치 도메인에 둔다. + */ +public final class RankingMvScoreCalculator { + + private static final double VIEW_WEIGHT = 0.1d; + private static final double LIKE_WEIGHT = 0.2d; + private static final double ORDER_WEIGHT = 0.6d; + + private RankingMvScoreCalculator() { + } + + /** + * @param viewCount 조회 수(비음수) + * @param likeCount 좋아요 수(비음수) + * @param soldQuantity 판매 수량(비음수) + * @return 가중 합산 점수 + */ + public static double score(long viewCount, long likeCount, long soldQuantity) { + if (viewCount < 0L || likeCount < 0L || soldQuantity < 0L) { + throw new IllegalArgumentException("metric counts must be non-negative"); + } + return VIEW_WEIGHT * viewCount + LIKE_WEIGHT * likeCount + ORDER_WEIGHT * soldQuantity; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java new file mode 100644 index 000000000..ad96d34d9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingScoreCandidate.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking.batch; + +/** + * 집계 단계에서 힙에 넣는 상품·점수 후보. + */ +public record RankingScoreCandidate(long productId, double score) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java new file mode 100644 index 000000000..08dcd44ab --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRankRow.java @@ -0,0 +1,9 @@ +package com.loopers.domain.ranking.batch; + +import java.math.BigDecimal; + +/** + * 스테이징 테이블에 저장할 순위 확정 행. + */ +public record RankingStagingRankRow(int rank, long productId, BigDecimal score) { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java new file mode 100644 index 000000000..9e5ae8096 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking.batch; + +import java.util.List; + +/** + * 랭킹 스테이징 MV 적재 포트. + */ +public interface RankingStagingRepository { + + void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey); + + void saveRankedRows(String periodType, String periodKey, List rows); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java new file mode 100644 index 000000000..50866b2a8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingTop100Accumulator.java @@ -0,0 +1,62 @@ +package com.loopers.domain.ranking.batch; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; + +/** + * product_metrics 전체를 스캔하면서 상위 100개만 유지한다. 동점은 product_id 오름차순이 앞 순위. + */ +public class RankingTop100Accumulator { + + private static final int CAPACITY = 100; + + /** + * 힙 순서: 더 나쁜(점수 낮음, 동점이면 id 큼) 후보가 루트. + */ + private static final Comparator WORST_TO_BETTER = + Comparator.comparingDouble(RankingScoreCandidate::score) + .thenComparing(Comparator.comparingLong(RankingScoreCandidate::productId).reversed()); + + private final PriorityQueue minHeap = new PriorityQueue<>(WORST_TO_BETTER); + + /** + * 후보를 반영한다. 상위 100을 넘으면 가장 낮은 후보를 제거한다. + */ + public void accept(RankingScoreCandidate candidate) { + if (minHeap.size() < CAPACITY) { + minHeap.add(candidate); + return; + } + RankingScoreCandidate worst = minHeap.peek(); + if (worst == null) { + return; + } + if (WORST_TO_BETTER.compare(worst, candidate) < 0) { + minHeap.poll(); + minHeap.add(candidate); + } + } + + /** + * rank 1(최고 점수)부터 오름차순으로 정렬된 행 목록을 만든다. + */ + public List toSortedRankRows() { + if (minHeap.isEmpty()) { + return List.of(); + } + List sorted = new ArrayList<>(minHeap); + sorted.sort( + Comparator.comparingDouble(RankingScoreCandidate::score).reversed() + .thenComparingLong(RankingScoreCandidate::productId) + ); + List rows = new ArrayList<>(sorted.size()); + int rank = 1; + for (RankingScoreCandidate c : sorted) { + rows.add(new RankingStagingRankRow(rank++, c.productId(), BigDecimal.valueOf(c.score()))); + } + return rows; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java new file mode 100644 index 000000000..ccbcded67 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingEntity.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.ranking.batch; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table( + name = "mv_product_rank_staging", + uniqueConstraints = @UniqueConstraint( + name = "uk_mv_rank_staging_period_product", + columnNames = {"period_type", "period_key", "product_id"} + ) +) +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class MvProductRankStagingEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_type", nullable = false, length = 16) + private String periodType; + + @Column(name = "period_key", nullable = false, length = 16) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rankValue; + + @Column(name = "score", nullable = false, precision = 24, scale = 8) + private BigDecimal score; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java new file mode 100644 index 000000000..75803f3a6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/MvProductRankStagingJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.ranking.batch; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankStagingJpaRepository extends JpaRepository { + + void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey); + + List findByPeriodTypeAndPeriodKeyOrderByRankValueAsc(String periodType, String periodKey); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java new file mode 100644 index 000000000..59f7f2020 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsEntity.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.ranking.batch; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +/** + * 배치가 {@code product_metrics}를 읽기 위한 매핑 엔티티(스트리머와 동일 테이블). + */ +@Entity +@Table(name = "product_metrics") +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class ProductMetricsEntity { + + @Id + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "sold_quantity", nullable = false) + private long soldQuantity; + + @Column(name = "last_event_occurred_at") + private Instant lastEventOccurredAt; + + @Column(name = "last_like_event_occurred_at") + private Instant lastLikeEventOccurredAt; + + @Column(name = "last_view_event_occurred_at") + private Instant lastViewEventOccurredAt; + + @Column(name = "last_sold_event_occurred_at") + private Instant lastSoldEventOccurredAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + public static ProductMetricsEntity forRankingRead( + long productId, + long likeCount, + long viewCount, + long soldQuantity, + Instant updatedAt + ) { + ProductMetricsEntity e = new ProductMetricsEntity(); + e.setProductId(productId); + e.setLikeCount(likeCount); + e.setViewCount(viewCount); + e.setSoldQuantity(soldQuantity); + e.setUpdatedAt(updatedAt); + return e; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..15a50c9ff --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/ProductMetricsJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.ranking.batch; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java new file mode 100644 index 000000000..06ba6f5dd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.ranking.batch; + +import com.loopers.domain.ranking.batch.RankingStagingRankRow; +import com.loopers.domain.ranking.batch.RankingStagingRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Repository +public class RankingStagingRepositoryImpl implements RankingStagingRepository { + + private final MvProductRankStagingJpaRepository jpaRepository; + + public RankingStagingRepositoryImpl(MvProductRankStagingJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + @Transactional + public void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey) { + jpaRepository.deleteByPeriodTypeAndPeriodKey(periodType, periodKey); + } + + @Override + @Transactional + public void saveRankedRows(String periodType, String periodKey, List rows) { + Instant now = Instant.now(); + List entities = new ArrayList<>(rows.size()); + for (RankingStagingRankRow row : rows) { + MvProductRankStagingEntity e = new MvProductRankStagingEntity(); + e.setPeriodType(periodType); + e.setPeriodKey(periodKey); + e.setProductId(row.productId()); + e.setRankValue(row.rank()); + e.setScore(row.score()); + e.setVersion(0); + e.setUpdatedAt(now); + entities.add(e); + } + jpaRepository.saveAll(entities); + } +} diff --git a/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql b/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql new file mode 100644 index 000000000..657325add --- /dev/null +++ b/apps/commerce-batch/src/main/resources/db/migration/V2__mv_product_rank_staging.sql @@ -0,0 +1,14 @@ +-- Round 10: 집계 결과를 MV 반영 전에 담는 스테이징 테이블 +CREATE TABLE IF NOT EXISTS mv_product_rank_staging ( + id BIGINT NOT NULL AUTO_INCREMENT, + period_type VARCHAR(16) NOT NULL, + period_key VARCHAR(16) NOT NULL, + product_id BIGINT NOT NULL, + `rank` INT NOT NULL, + score DECIMAL(24, 8) NOT NULL, + version INT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mv_rank_staging_period_product (period_type, period_key, product_id), + KEY idx_mv_rank_staging_period_rank (period_type, period_key, `rank`) +); diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java new file mode 100644 index 000000000..005e6887f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingMvScoreCalculatorTest.java @@ -0,0 +1,24 @@ +package com.loopers.domain.ranking.batch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingMvScoreCalculatorTest { + + @Test + @DisplayName("조회·좋아요·판매에 기본 가중치(0.1,0.2,0.6)를 곱해 합산한다.") + void score_withUnitCounts_matchesWeightedSum() { + double s = RankingMvScoreCalculator.score(1L, 1L, 1L); + assertThat(s).isEqualTo(0.1d + 0.2d + 0.6d); + } + + @Test + @DisplayName("음수 카운트면 IllegalArgumentException이다.") + void score_withNegativeCount_shouldFail() { + assertThatThrownBy(() -> RankingMvScoreCalculator.score(-1L, 0L, 0L)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java new file mode 100644 index 000000000..d2b19a9eb --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingTop100AccumulatorTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.ranking.batch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class RankingTop100AccumulatorTest { + + @Test + @DisplayName("후보가 100개 이하면 모두 rank에 포함된다.") + void toSortedRankRows_whenAtMost100_returnsAllOrdered() { + RankingTop100Accumulator acc = new RankingTop100Accumulator(); + acc.accept(new RankingScoreCandidate(2L, 10.0d)); + acc.accept(new RankingScoreCandidate(1L, 20.0d)); + + var rows = acc.toSortedRankRows(); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0).rank()).isEqualTo(1); + assertThat(rows.get(0).productId()).isEqualTo(1L); + assertThat(rows.get(1).productId()).isEqualTo(2L); + } + + @Test + @DisplayName("후보가 100개를 넘으면 점수 하위는 제외된다.") + void toSortedRankRows_whenMoreThan100_keepsTop100ByScore() { + RankingTop100Accumulator acc = new RankingTop100Accumulator(); + for (long i = 1L; i <= 100L; i++) { + acc.accept(new RankingScoreCandidate(i, (double) i)); + } + acc.accept(new RankingScoreCandidate(999L, 0.5d)); + + var rows = acc.toSortedRankRows(); + + assertThat(rows).hasSize(100); + assertThat(rows.get(0).productId()).isEqualTo(100L); + assertThat(rows.get(99).productId()).isEqualTo(1L); + assertThat(rows.stream().anyMatch(r -> r.productId() == 999L)).isFalse(); + } + + @Test + @DisplayName("동점이면 product_id가 작은 쪽이 더 높은 순위다.") + void toSortedRankRows_whenScoreTie_ordersByProductIdAsc() { + RankingTop100Accumulator acc = new RankingTop100Accumulator(); + acc.accept(new RankingScoreCandidate(20L, 5.0d)); + acc.accept(new RankingScoreCandidate(10L, 5.0d)); + + var rows = acc.toSortedRankRows(); + + assertThat(rows.get(0).productId()).isEqualTo(10L); + assertThat(rows.get(1).productId()).isEqualTo(20L); + } + + @Test + @DisplayName("동점 상위 100 경계에서 id가 작은 상품이 남는다.") + void accept_whenTieAtBoundary_prefersLowerProductId() { + RankingTop100Accumulator acc = new RankingTop100Accumulator(); + LongStream.rangeClosed(1L, 100L).forEach(i -> acc.accept(new RankingScoreCandidate(i, 1.0d))); + acc.accept(new RankingScoreCandidate(200L, 1.0d)); + + var rows = acc.toSortedRankRows(); + + assertThat(rows).hasSize(100); + assertThat(rows.stream().anyMatch(r -> r.productId() == 200L)).isFalse(); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java new file mode 100644 index 000000000..53cd8ddec --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.ranking.batch; + +import com.loopers.domain.ranking.batch.RankingStagingRankRow; +import com.loopers.domain.ranking.batch.RankingStagingRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@Import(MySqlTestContainersConfig.class) +class RankingStagingRepositoryIntegrationTest { + + @Autowired + private RankingStagingRepository rankingStagingRepository; + + @Autowired + private MvProductRankStagingJpaRepository stagingJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("삭제 후 순위 행을 저장하면 조회 시 rank 오름차순이다.") + void saveRankedRows_afterDelete_returnsOrdered() { + rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", "2026W15"); + rankingStagingRepository.saveRankedRows( + "WEEKLY", + "2026W15", + List.of( + new RankingStagingRankRow(1, 10L, new BigDecimal("9.00")), + new RankingStagingRankRow(2, 20L, new BigDecimal("1.00")) + ) + ); + + List rows = + stagingJpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc("WEEKLY", "2026W15"); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0).getRankValue()).isEqualTo(1); + assertThat(rows.get(0).getProductId()).isEqualTo(10L); + assertThat(rows.get(1).getRankValue()).isEqualTo(2); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java index 3675ec065..072f62d12 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java @@ -2,10 +2,14 @@ import com.loopers.batch.ranking.job.RankingBatchJobConfig; import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.infrastructure.ranking.batch.MvProductRankStagingJpaRepository; +import com.loopers.infrastructure.ranking.batch.ProductMetricsEntity; +import com.loopers.infrastructure.ranking.batch.ProductMetricsJpaRepository; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.RedisCleanUp; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.batch.core.ExitStatus; @@ -19,6 +23,10 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -43,6 +51,23 @@ class RankingBatchJobE2ETest { @Autowired private RedisCleanUp redisCleanUp; + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private MvProductRankStagingJpaRepository mvProductRankStagingJpaRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @BeforeEach + void cleanDb() { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + mvProductRankStagingJpaRepository.deleteAllInBatch(); + productMetricsJpaRepository.deleteAllInBatch(); + }); + } + @AfterEach void tearDown() { redisCleanUp.truncateAll(); @@ -65,6 +90,11 @@ void launchJob_whenPeriodMissing_shouldFailValidation() { @DisplayName("유효한 period·periodKey면 Job이 COMPLETED 된다.") void launchJob_whenValidParameters_shouldComplete() throws Exception { jobLauncherTestUtils.setJob(job); + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + productMetricsJpaRepository.save(ProductMetricsEntity.forRankingRead(1L, 0L, 0L, 10L, at)); + productMetricsJpaRepository.save(ProductMetricsEntity.forRankingRead(2L, 0L, 0L, 5L, at)); + }); var execution = jobLauncherTestUtils.launchJob( new JobParametersBuilder() .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") @@ -73,9 +103,18 @@ void launchJob_whenValidParameters_shouldComplete() throws Exception { .toJobParameters() ); + var staging = mvProductRankStagingJpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc( + "WEEKLY", + "2026W15" + ); + assertAll( () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), - () -> assertThat(execution.getStepExecutions()).hasSize(4) + () -> assertThat(execution.getStepExecutions()).hasSize(4), + () -> assertThat(staging).hasSize(2), + () -> assertThat(staging.get(0).getProductId()).isEqualTo(1L), + () -> assertThat(staging.get(0).getRankValue()).isEqualTo(1), + () -> assertThat(staging.get(1).getProductId()).isEqualTo(2L) ); } } From 4047588fdb49eef9ac3e2d117e27b4b25b2a28c4 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 13:42:48 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EB=9E=AD=ED=82=B9=20=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=9E=91=EC=97=85=EC=9D=98=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingBatchJobConfig에 랭킹 발행을 위한 Tasklet을 추가하여 스테이징된 랭킹 데이터를 검증하고 주간 및 월간 MV를 교체하는 기능을 구현. - RankingStagingSnapshotValidator 클래스를 추가하여 스테이징 데이터의 유효성을 검사하는 로직을 구현. - ProductRankMvPublishRepository 및 그 구현체를 추가하여 MV 데이터를 원자적으로 교체하는 기능을 제공. - RankingStagingRepository에 findRankedRows 메서드를 추가하여 스테이징된 랭킹 데이터를 조회하는 기능을 구현. - 관련 테스트 케이스를 추가하여 발행 및 검증 로직의 동작을 검증. --- .../ranking/job/RankingBatchJobConfig.java | 86 +++++++++++++----- .../batch/RankingStagingRepository.java | 5 ++ .../RankingStagingSnapshotValidator.java | 42 +++++++++ .../mv/ProductRankMvPublishRepository.java | 14 +++ .../batch/RankingStagingRepositoryImpl.java | 28 ++++++ .../mv/MvProductRankMonthlyJpaRepository.java | 2 + .../mv/MvProductRankWeeklyJpaRepository.java | 2 + .../ProductRankMvPublishRepositoryImpl.java | 88 +++++++++++++++++++ .../RankingStagingSnapshotValidatorTest.java | 68 ++++++++++++++ ...nkingStagingRepositoryIntegrationTest.java | 21 +++++ ...ankMvPublishRepositoryIntegrationTest.java | 88 +++++++++++++++++++ .../job/ranking/RankingBatchJobE2ETest.java | 54 ++++++++---- 12 files changed, 458 insertions(+), 40 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java index 9dab8b226..f10bae1c9 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java @@ -5,8 +5,12 @@ import com.loopers.domain.ranking.batch.RankingBatchJobParameters; import com.loopers.domain.ranking.batch.RankingMvScoreCalculator; import com.loopers.domain.ranking.batch.RankingScoreCandidate; +import com.loopers.domain.ranking.batch.RankingStagingRankRow; import com.loopers.domain.ranking.batch.RankingStagingRepository; +import com.loopers.domain.ranking.batch.RankingStagingSnapshotValidator; import com.loopers.domain.ranking.batch.RankingTop100Accumulator; +import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository; +import com.loopers.domain.ranking.mv.ProductRankMvRow; import com.loopers.infrastructure.ranking.batch.ProductMetricsEntity; import com.loopers.infrastructure.ranking.batch.RedisRankingBatchLock; import jakarta.persistence.EntityManagerFactory; @@ -43,6 +47,9 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.transaction.PlatformTransactionManager; +import java.time.Instant; +import java.util.List; + /** * Round 10 — 랭킹 MV 배치 Job(3단계): 파라미터 검증 → period 락 → staging 정리(자리) → 집계(자리) → publish(자리). */ @@ -78,16 +85,6 @@ private JobRepository jobRepository() { return jobRepositoryProvider.getObject(); } - /** - * 리소스 없는 트랜잭션 매니저를 생성한다. - * - * @return ResourcelessTransactionManager - */ - @Bean - public ResourcelessTransactionManager rankingBatchResourcelessTransactionManager() { - return new ResourcelessTransactionManager(); - } - /** * Redis 랭킹 배치 락을 생성한다. * @@ -323,11 +320,55 @@ public ExitStatus afterStep(StepExecution stepExecution) { } }; } - + + /** + * 랭킹 배치 발행을 생성한다. + * + * @param rankingStagingRepository RankingStagingRepository + * @param productRankMvPublishRepository ProductRankMvPublishRepository + * @param period 기간 + * @param periodKey 기간 키 + * @return Tasklet + */ + @Bean + @StepScope + public Tasklet rankingPublishTasklet( + RankingStagingRepository rankingStagingRepository, + ProductRankMvPublishRepository productRankMvPublishRepository, + @Value("#{jobParameters['period']}") String period, + @Value("#{jobParameters['periodKey']}") String periodKey + ) { + return (contribution, chunkContext) -> { + List staged = rankingStagingRepository.findRankedRows(period, periodKey); + try { + RankingStagingSnapshotValidator.validateOrThrow(staged); + } catch (IllegalArgumentException e) { + throw new UnexpectedJobExecutionException("스테이징 검증 실패: " + e.getMessage(), e); + } + Instant publishedAt = Instant.now(); + int snapshotVersion = 1; + List mvRows = staged.stream() + .map(r -> ProductRankMvRow.newRow( + periodKey, + r.productId(), + r.rank(), + r.score(), + snapshotVersion, + publishedAt + )) + .toList(); + RankingBatchJobParameters.Period p = RankingBatchJobParameters.Period.parse(period); + switch (p) { + case WEEKLY -> productRankMvPublishRepository.replaceWeeklyPeriod(periodKey, mvRows, publishedAt); + case MONTHLY -> productRankMvPublishRepository.replaceMonthlyPeriod(periodKey, mvRows, publishedAt); + } + return RepeatStatus.FINISHED; + }; + } + /** * 랭킹 배치 Job을 생성한다. * - * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager * @param rankingJobParametersValidator JobParametersValidator * @param rankingBatchLockReleaseListener JobExecutionListener * @param periodLockStep Step @@ -338,7 +379,6 @@ public ExitStatus afterStep(StepExecution stepExecution) { */ @Bean(JOB_NAME) public Job rankingProductMvJob( - ResourcelessTransactionManager rankingBatchResourcelessTransactionManager, JobParametersValidator rankingJobParametersValidator, JobExecutionListener rankingBatchLockReleaseListener, @Qualifier(STEP_PERIOD_LOCK) Step periodLockStep, @@ -361,17 +401,13 @@ public Job rankingProductMvJob( /** * 랭킹 배치 락 획득을 생성한다. * - * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager * @param rankingPeriodLockTasklet Tasklet * @return Step */ @Bean(STEP_PERIOD_LOCK) - public Step periodLockStep( - ResourcelessTransactionManager rankingBatchResourcelessTransactionManager, - Tasklet rankingPeriodLockTasklet - ) { + public Step periodLockStep(Tasklet rankingPeriodLockTasklet) { return new StepBuilder(STEP_PERIOD_LOCK, jobRepository()) - .tasklet(rankingPeriodLockTasklet, rankingBatchResourcelessTransactionManager) + .tasklet(rankingPeriodLockTasklet, new ResourcelessTransactionManager()) .listener(stepMonitorListener) .build(); } @@ -379,7 +415,6 @@ public Step periodLockStep( /** * 랭킹 배치 스테이징 정리를 생성한다. * - * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager * @return Step */ @Bean(STEP_STAGING_CLEANUP) @@ -422,14 +457,17 @@ public Step aggregateStep( /** * 랭킹 배치 발행을 생성한다. * - * @param rankingBatchResourcelessTransactionManager ResourcelessTransactionManager + * @param transactionManager PlatformTransactionManager + * @param rankingPublishTasklet Tasklet * @return Step */ @Bean(STEP_PUBLISH) - public Step publishStep(ResourcelessTransactionManager rankingBatchResourcelessTransactionManager) { - Tasklet noop = (contribution, chunkContext) -> RepeatStatus.FINISHED; + public Step publishStep( + PlatformTransactionManager transactionManager, + Tasklet rankingPublishTasklet + ) { return new StepBuilder(STEP_PUBLISH, jobRepository()) - .tasklet(noop, rankingBatchResourcelessTransactionManager) + .tasklet(rankingPublishTasklet, transactionManager) .listener(stepMonitorListener) .build(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java index 9e5ae8096..1c2c12368 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingRepository.java @@ -10,4 +10,9 @@ public interface RankingStagingRepository { void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey); void saveRankedRows(String periodType, String periodKey, List rows); + + /** + * publish 직전 조회. rank 오름차순. + */ + List findRankedRows(String periodType, String periodKey); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java new file mode 100644 index 000000000..53d08487a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidator.java @@ -0,0 +1,42 @@ +package com.loopers.domain.ranking.batch; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Option A publish 직전 스테이징 스냅샷 검증. + */ +public final class RankingStagingSnapshotValidator { + + private static final int TOP_LIMIT = 100; + + private RankingStagingSnapshotValidator() { + } + + /** + * 검증에 실패하면 {@link IllegalArgumentException}을 던진다. + */ + public static void validateOrThrow(List rows) { + if (rows.size() > TOP_LIMIT) { + throw new IllegalArgumentException( + "스테이징 행 수는 " + TOP_LIMIT + "을 넘을 수 없습니다. 실제: " + rows.size() + ); + } + for (int i = 0; i < rows.size(); i++) { + int expectedRank = i + 1; + if (rows.get(i).rank() != expectedRank) { + throw new IllegalArgumentException( + "rank는 1부터 연속이어야 합니다. 인덱스 " + i + "에서 기대 " + expectedRank + + ", 실제 " + rows.get(i).rank() + ); + } + } + Set productIds = new HashSet<>(); + for (RankingStagingRankRow row : rows) { + if (!productIds.add(row.productId())) { + throw new IllegalArgumentException("product_id가 중복입니다: " + row.productId()); + } + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java new file mode 100644 index 000000000..4467c5696 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/mv/ProductRankMvPublishRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.ranking.mv; + +import java.time.Instant; +import java.util.List; + +/** + * 주간/월간 MV를 스테이징 검증 이후 원자적으로 교체한다(Option A publish). + */ +public interface ProductRankMvPublishRepository { + + void replaceWeeklyPeriod(String periodKey, List rows, Instant publishedAt); + + void replaceMonthlyPeriod(String periodKey, List rows, Instant publishedAt); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java index 06ba6f5dd..63ae67865 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryImpl.java @@ -18,12 +18,25 @@ public RankingStagingRepositoryImpl(MvProductRankStagingJpaRepository jpaReposit this.jpaRepository = jpaRepository; } + /** + * 스테이징 MV를 삭제한다. + * + * @param periodType 기간 타입 + * @param periodKey 기간 키 + */ @Override @Transactional public void deleteByPeriodTypeAndPeriodKey(String periodType, String periodKey) { jpaRepository.deleteByPeriodTypeAndPeriodKey(periodType, periodKey); } + /** + * 스테이징 MV를 저장한다. + * + * @param periodType 기간 타입 + * @param periodKey 기간 키 + * @param rows 랭킹 스테이징 행 + */ @Override @Transactional public void saveRankedRows(String periodType, String periodKey, List rows) { @@ -42,4 +55,19 @@ public void saveRankedRows(String periodType, String periodKey, List findRankedRows(String periodType, String periodKey) { + return jpaRepository.findByPeriodTypeAndPeriodKeyOrderByRankValueAsc(periodType, periodKey).stream() + .map(e -> new RankingStagingRankRow(e.getRankValue(), e.getProductId(), e.getScore())) + .toList(); + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java index 60dcf82d3..fb08f4b60 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java @@ -7,4 +7,6 @@ public interface MvProductRankMonthlyJpaRepository extends JpaRepository { List findByPeriodKeyOrderByRankValueAsc(String periodKey); + + void deleteByPeriodKey(String periodKey); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java index e5f83456e..f93edbd83 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java @@ -7,4 +7,6 @@ public interface MvProductRankWeeklyJpaRepository extends JpaRepository { List findByPeriodKeyOrderByRankValueAsc(String periodKey); + + void deleteByPeriodKey(String periodKey); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java new file mode 100644 index 000000000..892ff0902 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java @@ -0,0 +1,88 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository; +import com.loopers.domain.ranking.mv.ProductRankMvRow; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Repository +public class ProductRankMvPublishRepositoryImpl implements ProductRankMvPublishRepository { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + public ProductRankMvPublishRepositoryImpl( + MvProductRankWeeklyJpaRepository weeklyJpaRepository, + MvProductRankMonthlyJpaRepository monthlyJpaRepository + ) { + this.weeklyJpaRepository = weeklyJpaRepository; + this.monthlyJpaRepository = monthlyJpaRepository; + } + + /** + * 주간 랭킹 MV를 대체한다. + * + * @param periodKey 기간 키 + * @param rows 랭킹 행 + * @param publishedAt 발행 시간 + */ + @Override + @Transactional + public void replaceWeeklyPeriod(String periodKey, List rows, Instant publishedAt) { + weeklyJpaRepository.deleteByPeriodKey(periodKey); + weeklyJpaRepository.saveAll(rows.stream().map(r -> toWeeklyEntity(r, publishedAt)).toList()); + } + + /** + * 월간 랭킹 MV를 대체한다. + * + * @param periodKey 기간 키 + * @param rows 랭킹 행 + * @param publishedAt 발행 시간 + */ + @Override + @Transactional + public void replaceMonthlyPeriod(String periodKey, List rows, Instant publishedAt) { + monthlyJpaRepository.deleteByPeriodKey(periodKey); + monthlyJpaRepository.saveAll(rows.stream().map(r -> toMonthlyEntity(r, publishedAt)).toList()); + } + + /** + * 주간 랭킹 MV 엔티티를 생성한다. + * + * @param row 랭킹 행 + * @param publishedAt 발행 시간 + * @return 주간 랭킹 MV 엔티티 + */ + private static MvProductRankWeeklyEntity toWeeklyEntity(ProductRankMvRow row, Instant publishedAt) { + MvProductRankWeeklyEntity e = new MvProductRankWeeklyEntity(); + e.setPeriodKey(row.periodKey()); + e.setProductId(row.productId()); + e.setRankValue(row.rank()); + e.setScore(row.score()); + e.setVersion(row.version()); + e.setUpdatedAt(publishedAt); + return e; + } + + /** + * 월간 랭킹 MV 엔티티를 생성한다. + * + * @param row 랭킹 행 + * @param publishedAt 발행 시간 + * @return 월간 랭킹 MV 엔티티 + */ + private static MvProductRankMonthlyEntity toMonthlyEntity(ProductRankMvRow row, Instant publishedAt) { + MvProductRankMonthlyEntity e = new MvProductRankMonthlyEntity(); + e.setPeriodKey(row.periodKey()); + e.setProductId(row.productId()); + e.setRankValue(row.rank()); + e.setScore(row.score()); + e.setVersion(row.version()); + e.setUpdatedAt(publishedAt); + return e; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java new file mode 100644 index 000000000..0c3712a67 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/ranking/batch/RankingStagingSnapshotValidatorTest.java @@ -0,0 +1,68 @@ +package com.loopers.domain.ranking.batch; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingStagingSnapshotValidatorTest { + + @Test + @DisplayName("빈 스냅샷은 통과한다.") + void validate_whenEmpty_doesNotThrow() { + assertThatCode(() -> RankingStagingSnapshotValidator.validateOrThrow(List.of())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("rank가 1부터 연속이면 통과한다.") + void validate_whenRanksContiguous_doesNotThrow() { + var rows = List.of( + new RankingStagingRankRow(1, 10L, BigDecimal.ONE), + new RankingStagingRankRow(2, 20L, BigDecimal.TEN) + ); + assertThatCode(() -> RankingStagingSnapshotValidator.validateOrThrow(rows)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("행이 100개를 넘으면 실패한다.") + void validate_whenMoreThan100_shouldFail() { + var rows = new ArrayList(); + for (int i = 1; i <= 101; i++) { + rows.add(new RankingStagingRankRow(i, (long) i, BigDecimal.valueOf(i))); + } + assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("100"); + } + + @Test + @DisplayName("rank에 공백이 있으면 실패한다.") + void validate_whenRankGap_shouldFail() { + var rows = List.of( + new RankingStagingRankRow(1, 1L, BigDecimal.ONE), + new RankingStagingRankRow(3, 2L, BigDecimal.TEN) + ); + assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("연속"); + } + + @Test + @DisplayName("product_id 중복이면 실패한다.") + void validate_whenDuplicateProduct_shouldFail() { + var rows = List.of( + new RankingStagingRankRow(1, 1L, BigDecimal.ONE), + new RankingStagingRankRow(2, 1L, BigDecimal.TEN) + ); + assertThatThrownBy(() -> RankingStagingSnapshotValidator.validateOrThrow(rows)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("중복"); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java index 53cd8ddec..95864d083 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/batch/RankingStagingRepositoryIntegrationTest.java @@ -58,4 +58,25 @@ void saveRankedRows_afterDelete_returnsOrdered() { assertThat(rows.get(0).getProductId()).isEqualTo(10L); assertThat(rows.get(1).getRankValue()).isEqualTo(2); } + + @Test + @DisplayName("findRankedRows는 rank 오름차순 DTO 목록을 반환한다.") + void findRankedRows_returnsOrderedDtos() { + rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", "2026W15"); + rankingStagingRepository.saveRankedRows( + "WEEKLY", + "2026W15", + List.of( + new RankingStagingRankRow(1, 10L, new BigDecimal("9.00")), + new RankingStagingRankRow(2, 20L, new BigDecimal("1.00")) + ) + ); + + List dtos = rankingStagingRepository.findRankedRows("WEEKLY", "2026W15"); + + assertThat(dtos).hasSize(2); + assertThat(dtos.get(0).rank()).isEqualTo(1); + assertThat(dtos.get(0).productId()).isEqualTo(10L); + assertThat(dtos.get(1).rank()).isEqualTo(2); + } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java new file mode 100644 index 000000000..03986f0ed --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryIntegrationTest.java @@ -0,0 +1,88 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository; +import com.loopers.domain.ranking.mv.ProductRankMvRow; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@Import(MySqlTestContainersConfig.class) +class ProductRankMvPublishRepositoryIntegrationTest { + + @Autowired + private ProductRankMvPublishRepository productRankMvPublishRepository; + + @Autowired + private MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + @Autowired + private MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("주간 MV: 동일 period를 교체하면 이전 행이 사라지고 새 행만 남는다.") + void replaceWeeklyPeriod_secondPublish_replacesRows() { + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + String periodKey = "2026W15"; + productRankMvPublishRepository.replaceWeeklyPeriod( + periodKey, + List.of(ProductRankMvRow.newRow(periodKey, 1L, 1, new BigDecimal("5"), 1, at)), + at + ); + assertThat(weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey)).hasSize(1); + + productRankMvPublishRepository.replaceWeeklyPeriod( + periodKey, + List.of( + ProductRankMvRow.newRow(periodKey, 10L, 1, new BigDecimal("9"), 1, at), + ProductRankMvRow.newRow(periodKey, 20L, 2, new BigDecimal("1"), 1, at) + ), + at + ); + var rows = weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey); + assertThat(rows).hasSize(2); + assertThat(rows.get(0).getProductId()).isEqualTo(10L); + assertThat(rows.get(1).getProductId()).isEqualTo(20L); + } + + @Test + @DisplayName("월간 MV: 교체 후 rank 순으로 조회된다.") + void replaceMonthlyPeriod_ordersByRank() { + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + String periodKey = "202604"; + productRankMvPublishRepository.replaceMonthlyPeriod( + periodKey, + List.of( + ProductRankMvRow.newRow(periodKey, 2L, 2, BigDecimal.ONE, 1, at), + ProductRankMvRow.newRow(periodKey, 1L, 1, BigDecimal.TEN, 1, at) + ), + at + ); + var rows = monthlyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey); + assertThat(rows).hasSize(2); + assertThat(rows.get(0).getProductId()).isEqualTo(1L); + assertThat(rows.get(1).getProductId()).isEqualTo(2L); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java index 072f62d12..4abcd3a73 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java @@ -3,8 +3,7 @@ import com.loopers.batch.ranking.job.RankingBatchJobConfig; import com.loopers.domain.ranking.batch.RankingBatchJobParameters; import com.loopers.infrastructure.ranking.batch.MvProductRankStagingJpaRepository; -import com.loopers.infrastructure.ranking.batch.ProductMetricsEntity; -import com.loopers.infrastructure.ranking.batch.ProductMetricsJpaRepository; +import com.loopers.infrastructure.ranking.mv.MvProductRankWeeklyJpaRepository; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.testcontainers.RedisTestContainersConfig; import com.loopers.utils.RedisCleanUp; @@ -22,11 +21,11 @@ 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 org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -41,6 +40,14 @@ @Import({MySqlTestContainersConfig.class, RedisTestContainersConfig.class}) class RankingBatchJobE2ETest { + private static final List TRUNCATE_TABLES = List.of( + "mv_product_rank_staging", + "mv_product_rank_weekly", + "mv_product_rank_monthly", + "product_metrics", + "outbox_event" + ); + @Autowired private JobLauncherTestUtils jobLauncherTestUtils; @@ -52,20 +59,21 @@ class RankingBatchJobE2ETest { private RedisCleanUp redisCleanUp; @Autowired - private ProductMetricsJpaRepository productMetricsJpaRepository; + private MvProductRankStagingJpaRepository mvProductRankStagingJpaRepository; @Autowired - private MvProductRankStagingJpaRepository mvProductRankStagingJpaRepository; + private MvProductRankWeeklyJpaRepository mvProductRankWeeklyJpaRepository; @Autowired - private PlatformTransactionManager transactionManager; + private JdbcTemplate jdbcTemplate; @BeforeEach void cleanDb() { - new TransactionTemplate(transactionManager).executeWithoutResult(status -> { - mvProductRankStagingJpaRepository.deleteAllInBatch(); - productMetricsJpaRepository.deleteAllInBatch(); - }); + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + for (String table : TRUNCATE_TABLES) { + jdbcTemplate.execute("TRUNCATE TABLE `" + table + "`"); + } + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); } @AfterEach @@ -91,10 +99,20 @@ void launchJob_whenPeriodMissing_shouldFailValidation() { void launchJob_whenValidParameters_shouldComplete() throws Exception { jobLauncherTestUtils.setJob(job); Instant at = Instant.parse("2026-04-10T00:00:00Z"); - new TransactionTemplate(transactionManager).executeWithoutResult(status -> { - productMetricsJpaRepository.save(ProductMetricsEntity.forRankingRead(1L, 0L, 0L, 10L, at)); - productMetricsJpaRepository.save(ProductMetricsEntity.forRankingRead(2L, 0L, 0L, 5L, at)); - }); + jdbcTemplate.update( + """ + INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + 1L, 0L, 0L, 10L, java.sql.Timestamp.from(at) + ); + jdbcTemplate.update( + """ + INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + 2L, 0L, 0L, 5L, java.sql.Timestamp.from(at) + ); var execution = jobLauncherTestUtils.launchJob( new JobParametersBuilder() .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") @@ -107,6 +125,7 @@ void launchJob_whenValidParameters_shouldComplete() throws Exception { "WEEKLY", "2026W15" ); + var weeklyMv = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15"); assertAll( () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()), @@ -114,7 +133,10 @@ void launchJob_whenValidParameters_shouldComplete() throws Exception { () -> assertThat(staging).hasSize(2), () -> assertThat(staging.get(0).getProductId()).isEqualTo(1L), () -> assertThat(staging.get(0).getRankValue()).isEqualTo(1), - () -> assertThat(staging.get(1).getProductId()).isEqualTo(2L) + () -> assertThat(staging.get(1).getProductId()).isEqualTo(2L), + () -> assertThat(weeklyMv).hasSize(2), + () -> assertThat(weeklyMv.get(0).getProductId()).isEqualTo(1L), + () -> assertThat(weeklyMv.get(1).getProductId()).isEqualTo(2L) ); } } From ff0bdd2fc1d09b4920af0e1055b54a4969bfc085 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 14:10:31 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20MV=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingFacade에 주간 및 월간 MV 랭킹 조회를 위한 메서드를 추가하고, 기존 메서드를 수정하여 새로운 파라미터를 지원. - RankingMvPeriod, RankingMvRequestValidator 클래스를 추가하여 기간 및 요청 검증 로직을 구현. - RankingListSource에 MV_WEEKLY 및 MV_MONTHLY 항목을 추가하여 데이터 소스를 구분. - RankingQueryService에 MV 랭킹 페이지를 조회하는 메서드를 추가하고, 관련 엔티티 및 저장소를 구현. - 데이터베이스 마이그레이션 스크립트를 추가하여 MV 테이블을 생성. - 관련 테스트 케이스를 추가하여 새로운 기능의 동작을 검증. --- .../application/ranking/RankingFacade.java | 40 ++++- .../application/ranking/RankingListInfo.java | 2 + .../domain/ranking/RankingListSource.java | 8 +- .../domain/ranking/RankingMvPeriod.java | 20 +++ .../ranking/RankingMvReadRepository.java | 13 ++ .../ranking/RankingMvRequestValidator.java | 81 +++++++++ .../domain/ranking/RankingMvTableRow.java | 13 ++ .../domain/ranking/RankingQueryService.java | 101 ++++++++++++ .../mv/MvProductRankMonthlyEntity.java | 53 ++++++ .../mv/MvProductRankMonthlyJpaRepository.java | 10 ++ .../ranking/mv/MvProductRankWeeklyEntity.java | 53 ++++++ .../mv/MvProductRankWeeklyJpaRepository.java | 10 ++ .../mv/RankingMvReadRepositoryImpl.java | 47 ++++++ .../api/ranking/RankingV1ApiSpec.java | 19 ++- .../api/ranking/RankingV1Controller.java | 4 +- .../interfaces/api/ranking/RankingV1Dto.java | 2 +- .../V1__mv_product_rank_weekly_monthly.sql | 26 +++ .../ranking/RankingFacadeTest.java | 42 +++++ .../RankingMvRequestValidatorTest.java | 51 ++++++ .../ranking/RankingQueryServiceTest.java | 75 +++++++++ .../api/ranking/RankingV1ApiE2ETest.java | 156 ++++++++++++++++++ .../api/ranking/RankingV1ControllerTest.java | 8 +- http/commerce-api/ranking-v1.http | 9 + 23 files changed, 828 insertions(+), 15 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 71b90dede..52a336a8e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -1,9 +1,13 @@ package com.loopers.application.ranking; +import com.loopers.domain.ranking.RankingMvPeriod; +import com.loopers.domain.ranking.RankingMvRequestValidator; import com.loopers.domain.ranking.RankingPage; import com.loopers.domain.ranking.RankingQueryService; import com.loopers.domain.ranking.RankingRequestDate; import com.loopers.domain.ranking.RankingSnapshotCreateResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,7 +39,7 @@ public RankingFacade(RankingQueryService rankingQueryService) { */ @Transactional(readOnly = true) public RankingListInfo getRankings(String dateYyyyMmDdOptional, int page, int size) { - return getRankings(dateYyyyMmDdOptional, page, size, Optional.empty()); + return getRankings(dateYyyyMmDdOptional, null, null, page, size, Optional.empty()); } /** @@ -44,6 +48,40 @@ public RankingListInfo getRankings(String dateYyyyMmDdOptional, int page, int si @Transactional(readOnly = true) public RankingListInfo getRankings( String dateYyyyMmDdOptional, int page, int size, Optional rankingSnapshotId) { + return getRankings(dateYyyyMmDdOptional, null, null, page, size, rankingSnapshotId); + } + + /** + * 일간 Redis 또는 주간/월간 MV 랭킹 조회. + * + * @param periodRaw {@code WEEKLY} / {@code MONTHLY} (선택, 주간·월간 MV) + * @param periodKeyRaw 주간 yyyyWww, 월간 yyyyMM (선택) + */ + @Transactional(readOnly = true) + public RankingListInfo getRankings( + String dateYyyyMmDdOptional, + String periodRaw, + String periodKeyRaw, + int page, + int size, + Optional rankingSnapshotId) { + Optional dateOpt = Optional.ofNullable(dateYyyyMmDdOptional).filter(s -> !s.isBlank()); + boolean periodPresent = periodRaw != null && !periodRaw.isBlank(); + boolean periodKeyPresent = periodKeyRaw != null && !periodKeyRaw.isBlank(); + RankingMvRequestValidator.validateMvPairPresent(periodPresent, periodKeyPresent); + boolean mvRequested = periodPresent; + RankingMvRequestValidator.validateMutualExclusion(dateOpt, mvRequested); + if (mvRequested && rankingSnapshotId.filter(s -> !s.isBlank()).isPresent()) { + throw new CoreException( + ErrorType.BAD_REQUEST, + "주간/월간 조회에서는 rankingSnapshotId를 사용할 수 없습니다."); + } + if (mvRequested) { + RankingMvPeriod period = RankingMvRequestValidator.parsePeriod(periodRaw); + RankingMvRequestValidator.validatePeriodKey(period, periodKeyRaw); + RankingPage pageResult = rankingQueryService.loadMvPage(period, periodKeyRaw, page, size); + return RankingListInfo.from(pageResult); + } LocalDate date = RankingRequestDate.resolveOptionalYyyyMmDd(dateYyyyMmDdOptional); RankingPage pageResult = rankingQueryService.loadPage(date, page, size, rankingSnapshotId); return RankingListInfo.from(pageResult); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java index 2e9e9913e..6788020c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java @@ -51,6 +51,8 @@ private static String toDataSource(RankingListSource source) { case REDIS_ZSET_SNAPSHOT -> "REDIS_SNAPSHOT"; case FALLBACK_DB_LATEST -> "FALLBACK_LATEST"; case DEGRADED_EMPTY -> "DEGRADED"; + case MV_WEEKLY -> "MV_WEEKLY"; + case MV_MONTHLY -> "MV_MONTHLY"; }; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java index 465b3ea91..cefeb9079 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingListSource.java @@ -17,5 +17,11 @@ public enum RankingListSource { FALLBACK_DB_LATEST, /** Redis를 읽지 못했고 fallback도 쓰지 않았거나 결과가 비었을 때 */ - DEGRADED_EMPTY + DEGRADED_EMPTY, + + /** 주간 랭킹 MV({@code mv_product_rank_weekly}) */ + MV_WEEKLY, + + /** 월간 랭킹 MV({@code mv_product_rank_monthly}) */ + MV_MONTHLY } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java new file mode 100644 index 000000000..186610331 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvPeriod.java @@ -0,0 +1,20 @@ +package com.loopers.domain.ranking; + +/** + * 주간/월간 랭킹 MV 조회용 기간 구분. 배치 Job 파라미터 {@code period}와 동일한 대문자 값을 사용한다. + */ +public enum RankingMvPeriod { + WEEKLY, + MONTHLY; + + public static RankingMvPeriod parse(String raw) { + if (raw == null || raw.isBlank()) { + throw new IllegalArgumentException("period는 필수이며 비어 있으면 안 됩니다."); + } + try { + return RankingMvPeriod.valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("period는 WEEKLY 또는 MONTHLY만 허용됩니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java new file mode 100644 index 000000000..f637838ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking; + +import java.util.List; + +/** + * 주간/월간 랭킹 MV 읽기 포트. 구현은 infrastructure. + */ +public interface RankingMvReadRepository { + + List findWeeklyByPeriodKeyOrdered(String periodKey); + + List findMonthlyByPeriodKeyOrdered(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java new file mode 100644 index 000000000..f65d7e01b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvRequestValidator.java @@ -0,0 +1,81 @@ +package com.loopers.domain.ranking; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * GET 랭킹의 {@code period}/{@code periodKey} 검증. 배치 {@code RankingBatchJobParameters}와 동일 규칙. + */ +public final class RankingMvRequestValidator { + + private static final Pattern WEEKLY_KEY = Pattern.compile("^\\d{4}W\\d{2}$"); + private static final Pattern MONTHLY_KEY = Pattern.compile("^\\d{6}$"); + + private RankingMvRequestValidator() { + } + + public static void validateMutualExclusion( + Optional dateRaw, + boolean mvRequested) { + if (mvRequested && dateRaw.filter(s -> !s.isBlank()).isPresent()) { + throw new CoreException( + ErrorType.BAD_REQUEST, + "주간/월간(period·periodKey) 조회와 date는 함께 사용할 수 없습니다."); + } + } + + public static void validateMvPairPresent(boolean periodPresent, boolean periodKeyPresent) { + if (periodPresent != periodKeyPresent) { + throw new CoreException( + ErrorType.BAD_REQUEST, + "period와 periodKey는 함께 지정해야 합니다."); + } + } + + public static RankingMvPeriod parsePeriod(String periodRaw) { + try { + return RankingMvPeriod.parse(periodRaw); + } catch (IllegalArgumentException ex) { + throw new CoreException(ErrorType.BAD_REQUEST, ex.getMessage()); + } + } + + public static void validatePeriodKey(RankingMvPeriod period, String periodKeyRaw) { + if (periodKeyRaw == null || periodKeyRaw.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "periodKey는 필수이며 비어 있으면 안 됩니다."); + } + try { + switch (period) { + case WEEKLY -> validateWeeklyKey(periodKeyRaw); + case MONTHLY -> validateMonthlyKey(periodKeyRaw); + } + } catch (IllegalArgumentException ex) { + throw new CoreException(ErrorType.BAD_REQUEST, ex.getMessage()); + } + } + + private static void validateWeeklyKey(String periodKey) { + if (!WEEKLY_KEY.matcher(periodKey).matches()) { + throw new IllegalArgumentException( + "period가 WEEKLY일 때 periodKey는 yyyyWww 형식이어야 합니다. 예: 2026W15"); + } + int week = Integer.parseInt(periodKey.substring(periodKey.indexOf('W') + 1)); + if (week < 1 || week > 53) { + throw new IllegalArgumentException("주차는 01~53 범위여야 합니다."); + } + } + + private static void validateMonthlyKey(String periodKey) { + if (!MONTHLY_KEY.matcher(periodKey).matches()) { + throw new IllegalArgumentException( + "period가 MONTHLY일 때 periodKey는 yyyyMM 형식이어야 합니다. 예: 202604"); + } + int month = Integer.parseInt(periodKey.substring(4, 6)); + if (month < 1 || month > 12) { + throw new IllegalArgumentException("월은 01~12 범위여야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java new file mode 100644 index 000000000..93cb47e15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvTableRow.java @@ -0,0 +1,13 @@ +package com.loopers.domain.ranking; + +import java.math.BigDecimal; + +/** + * MV 테이블에서 읽은 한 행(순위·상품·점수). Hydration 전 단계. + * + * @param rankValue 전역 순위(1-based), 컬럼 {@code rank} + * @param productId 상품 ID + * @param score 배치가 저장한 점수 + */ +public record RankingMvTableRow(int rankValue, long productId, BigDecimal score) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java index 66cb6f617..f8ab16b7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java @@ -47,6 +47,7 @@ public class RankingQueryService { private final RankingReadRepository rankingReadRepository; private final RankingSnapshotRepository rankingSnapshotRepository; + private final RankingMvReadRepository rankingMvReadRepository; private final ProductRepository productRepository; private final BrandService brandService; private final LikeService likeService; @@ -57,6 +58,7 @@ public class RankingQueryService { public RankingQueryService( RankingReadRepository rankingReadRepository, RankingSnapshotRepository rankingSnapshotRepository, + RankingMvReadRepository rankingMvReadRepository, ProductRepository productRepository, BrandService brandService, LikeService likeService, @@ -65,6 +67,7 @@ public RankingQueryService( @Value("${app.ranking.snapshot-ttl-seconds:600}") long snapshotTtlSeconds) { this.rankingReadRepository = rankingReadRepository; this.rankingSnapshotRepository = rankingSnapshotRepository; + this.rankingMvReadRepository = rankingMvReadRepository; this.productRepository = productRepository; this.brandService = brandService; this.likeService = likeService; @@ -73,6 +76,104 @@ public RankingQueryService( this.snapshotTtlSeconds = snapshotTtlSeconds; } + /** + * 주간/월간 MV에서 랭킹 페이지를 조회한다. 행 수는 최대 100으로 캡한다. + * + * @param period WEEKLY 또는 MONTHLY + * @param periodKey 검증된 기간 키(주간 yyyyWww, 월간 yyyyMM) + * @param pageOneBased 페이지 (1부터) + * @param size 페이지 크기 + * @return MV 기준 랭킹 페이지 + */ + public RankingPage loadMvPage( + RankingMvPeriod period, + String periodKey, + int pageOneBased, + int size) { + validatePageAndSize(pageOneBased, size); + List all = switch (period) { + case WEEKLY -> rankingMvReadRepository.findWeeklyByPeriodKeyOrdered(periodKey); + case MONTHLY -> rankingMvReadRepository.findMonthlyByPeriodKeyOrdered(periodKey); + }; + RankingListSource listSource = switch (period) { + case WEEKLY -> RankingListSource.MV_WEEKLY; + case MONTHLY -> RankingListSource.MV_MONTHLY; + }; + List capped = all.stream().limit(100).toList(); + long total = capped.size(); + int totalPages = computeTotalPages(total, size); + if (total == 0L) { + return new RankingPage( + List.of(), pageOneBased, size, 0L, 0, listSource, null); + } + long startIndex = (long) (pageOneBased - 1) * size; + if (startIndex >= total) { + meterRegistry.counter("ranking.mv.page_beyond", "period", period.name()).increment(); + log.debug( + "ranking.mv.page_beyond period={} periodKey={} total={} page={} size={}", + period, + periodKey, + total, + pageOneBased, + size); + return new RankingPage( + List.of(), pageOneBased, size, total, totalPages, listSource, null); + } + int from = (int) startIndex; + int to = (int) Math.min(startIndex + size, total); + List slice = capped.subList(from, to); + List rows = hydrateMvRows(slice); + return new RankingPage(rows, pageOneBased, size, total, totalPages, listSource, null); + } + + /** + * 주간/월간 MV 행을 랭킹 행으로 변환한다. + * + * @param slice 주간/월간 MV 행 + * @return 랭킹 행 + */ + private List hydrateMvRows(List slice) { + if (slice.isEmpty()) { + return List.of(); + } + List parsedIds = slice.stream().map(RankingMvTableRow::productId).toList(); + Map productMap = productRepository.findByIdInAndNotDeletedAsMap(parsedIds); + List brandIds = productMap.values().stream() + .map(ProductModel::getBrandId) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map brandMap = brandIds.isEmpty() + ? Map.of() + : brandService.findByIdAndNotDeletedIn(brandIds); + Map likeMap = likeService.getLikeCountByProductIdsFromStats(parsedIds); + + List rows = new ArrayList<>(); + for (RankingMvTableRow row : slice) { + ProductModel product = productMap.get(row.productId()); + if (product == null) { + continue; + } + BrandModel brand = brandMap.get(product.getBrandId()); + if (brand == null) { + continue; + } + long likeCount = likeMap.getOrDefault(row.productId(), 0L); + rows.add(new RankingRow( + row.rankValue(), + row.productId(), + row.score().doubleValue(), + product.getName(), + product.getPrice(), + product.getBrandId(), + brand.getName(), + likeCount, + product.getStockQuantity() + )); + } + return rows; + } + /** * 일간 랭킹 페이지 조회 * @param rankingDate 랭킹 일자 diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java new file mode 100644 index 000000000..4e4dab824 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyEntity.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = @UniqueConstraint( + name = "uk_mv_product_rank_monthly_period_product", + columnNames = {"period_key", "product_id"} + ) +) +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class MvProductRankMonthlyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_key", nullable = false, length = 16) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rankValue; + + @Column(name = "score", nullable = false, precision = 24, scale = 8) + private BigDecimal score; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 000000000..60dcf82d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking.mv; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankValueAsc(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java new file mode 100644 index 000000000..8cbe76800 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyEntity.java @@ -0,0 +1,53 @@ +package com.loopers.infrastructure.ranking.mv; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.Instant; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = @UniqueConstraint( + name = "uk_mv_product_rank_weekly_period_product", + columnNames = {"period_key", "product_id"} + ) +) +@Getter +@Setter +@NoArgsConstructor(access = PROTECTED) +public class MvProductRankWeeklyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_key", nullable = false, length = 16) + private String periodKey; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "`rank`", nullable = false) + private int rankValue; + + @Column(name = "score", nullable = false, precision = 24, scale = 8) + private BigDecimal score; + + @Column(name = "version", nullable = false) + private int version; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 000000000..e5f83456e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.ranking.mv; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + List findByPeriodKeyOrderByRankValueAsc(String periodKey); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java new file mode 100644 index 000000000..76be9e360 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.ranking.mv; + +import com.loopers.domain.ranking.RankingMvReadRepository; +import com.loopers.domain.ranking.RankingMvTableRow; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class RankingMvReadRepositoryImpl implements RankingMvReadRepository { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + + public RankingMvReadRepositoryImpl( + MvProductRankWeeklyJpaRepository weeklyJpaRepository, + MvProductRankMonthlyJpaRepository monthlyJpaRepository) { + this.weeklyJpaRepository = weeklyJpaRepository; + this.monthlyJpaRepository = monthlyJpaRepository; + } + + /** + * 주간 랭킹 MV를 조회한다. + * + * @param periodKey 기간 키 + * @return 주간 랭킹 MV + */ + @Override + public List findWeeklyByPeriodKeyOrdered(String periodKey) { + return weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore())) + .toList(); + } + + /** + * 월간 랭킹 MV를 조회한다. + * + * @param periodKey 기간 키 + * @return 월간 랭킹 MV + */ + @Override + public List findMonthlyByPeriodKeyOrdered(String periodKey) { + return monthlyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index 0b29176e3..753368bcc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -8,25 +8,30 @@ import jakarta.validation.constraints.Min; import org.springframework.http.ResponseEntity; -@Tag(name = "Ranking V1 API", description = "일간 인기 상품 랭킹 API (비로그인 허용)") +@Tag(name = "Ranking V1 API", description = "인기 상품 랭킹 API: 일간(Redis)·주간/월간(MV) (비로그인 허용)") public interface RankingV1ApiSpec { @Operation( summary = "랭킹 목록 조회", - description = "지정 일자의 일간 랭킹을 page·size 오프셋으로 조회합니다. Redis ZSET(ranking:all:{yyyyMMdd}) 점수 내림차순이며, " - + "date 생략 시 오늘(Asia/Seoul)입니다. `rankingSnapshotId`를 주면 POST /rankings/snapshots로 만든 스냅샷 ZSET에서만 페이징해 순서가 고정됩니다. " - + "라이브 조회는 실시간 갱신으로 재요청 시 항목이 달라질 수 있습니다. " - + "응답 `dataSource`는 REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED를 구분합니다. " + description = "일간: Redis ZSET(ranking:all:{yyyyMMdd}) 점수 내림차순, date 생략 시 오늘(Asia/Seoul). " + + "`rankingSnapshotId`를 주면 POST /rankings/snapshots로 만든 스냅샷 ZSET에서만 페이징합니다. " + + "주간/월간: `period=WEEKLY|MONTHLY`와 `periodKey`(주간 yyyyWww, 월간 yyyyMM)를 함께 지정하면 DB MV에서 조회합니다(일간과 date·스냅샷과 동시 사용 불가). " + + "MV는 최대 100행 기준으로 total·페이징하며, 요청 page가 범위를 넘으면 빈 content와 total 유지. " + + "응답 `dataSource`: REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED·MV_WEEKLY·MV_MONTHLY. " + "동일 값을 헤더 `X-Loopers-Ranking-Data-Source`로도 내려줍니다." ) ResponseEntity> getRankings( - @Parameter(description = "기준 일자 yyyyMMdd (선택, 기본 오늘 Asia/Seoul)") + @Parameter(description = "기준 일자 yyyyMMdd (일간 전용, 선택, 기본 오늘 Asia/Seoul)") String date, + @Parameter(description = "WEEKLY 또는 MONTHLY (주간/월간 MV, periodKey와 함께 지정)") + String period, + @Parameter(description = "주간 yyyyWww, 월간 yyyyMM") + String periodKey, @Parameter(description = "페이지 번호 (1부터)") @Min(1) int page, @Parameter(description = "페이지 크기 (1~100)") @Min(1) @Max(100) int size, - @Parameter(description = "스냅샷 UUID (선택, POST /rankings/snapshots에서 발급)") + @Parameter(description = "스냅샷 UUID (일간 전용, POST /rankings/snapshots에서 발급)") String rankingSnapshotId ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index 4e018bd40..d51c73fa1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -44,12 +44,14 @@ public RankingV1Controller(RankingFacade rankingFacade) { @Override public ResponseEntity> getRankings( @RequestParam(required = false) String date, + @RequestParam(required = false) String period, + @RequestParam(required = false) String periodKey, @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size, @RequestParam(required = false) String rankingSnapshotId ) { Optional snap = Optional.ofNullable(rankingSnapshotId).filter(s -> !s.isBlank()); - RankingListInfo listResult = rankingFacade.getRankings(date, page, size, snap); + RankingListInfo listResult = rankingFacade.getRankings(date, period, periodKey, page, size, snap); return ResponseEntity.ok() .header(HEADER_RANKING_DATA_SOURCE, listResult.dataSource()) .body(ApiResponse.success(RankingV1Dto.ListResponse.from(listResult))); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 1b8adaf2e..5b4dbefba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -69,7 +69,7 @@ public record ListResponse( long totalElements, @Schema(description = "총 페이지 수(ceil(totalElements/size), totalElements=0이면 0)") int totalPages, - @Schema(description = "REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED") + @Schema(description = "REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED·MV_WEEKLY·MV_MONTHLY") String dataSource, @Schema(description = "스냅샷 조회 시 echo, 라이브 조회면 null") String rankingSnapshotId diff --git a/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql b/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql new file mode 100644 index 000000000..69e60e468 --- /dev/null +++ b/apps/commerce-api/src/main/resources/db/migration/V1__mv_product_rank_weekly_monthly.sql @@ -0,0 +1,26 @@ +-- Round 10: 주간/월간 랭킹 MV (commerce-api Flyway; 배치와 동일 스키마, CREATE IF NOT EXISTS로 공존) +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT NOT NULL AUTO_INCREMENT, + period_key VARCHAR(16) NOT NULL, + product_id BIGINT NOT NULL, + `rank` INT NOT NULL, + score DECIMAL(24, 8) NOT NULL, + version INT NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mv_product_rank_weekly_period_product (period_key, product_id), + KEY idx_mv_product_rank_weekly_period_rank (period_key, `rank`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT, + period_key VARCHAR(16) NOT NULL, + product_id BIGINT NOT NULL, + `rank` INT NOT NULL, + score DECIMAL(24, 8) NOT NULL, + version INT NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mv_product_rank_monthly_period_product (period_key, product_id), + KEY idx_mv_product_rank_monthly_period_rank (period_key, `rank`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index e82a099b8..cd3369ddb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -1,6 +1,7 @@ package com.loopers.application.ranking; import com.loopers.domain.ranking.RankingListSource; +import com.loopers.domain.ranking.RankingMvPeriod; import com.loopers.domain.ranking.RankingPage; import com.loopers.domain.ranking.RankingQueryService; import com.loopers.domain.ranking.RankingRow; @@ -16,6 +17,7 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -65,4 +67,44 @@ void getRankings_whenInvalidDate_shouldThrow() { assertThatThrownBy(() -> rankingFacade.getRankings("bad", 1, 20)) .isInstanceOf(CoreException.class); } + + @Test + @DisplayName("period·periodKey가 있으면 loadMvPage로 위임한다") + void getRankings_whenWeeklyMv_shouldDelegateLoadMvPage() { + RankingPage domainPage = new RankingPage( + List.of(new RankingRow(1, 1L, 1.0d, "n", BigDecimal.ONE, 1L, "b", 0L, 0)), + 1, + 20, + 1L, + 1, + RankingListSource.MV_WEEKLY, + null + ); + when(rankingQueryService.loadMvPage(RankingMvPeriod.WEEKLY, "2026W15", 1, 20)) + .thenReturn(domainPage); + + RankingListInfo out = rankingFacade.getRankings( + null, "WEEKLY", "2026W15", 1, 20, Optional.empty()); + + verify(rankingQueryService).loadMvPage(RankingMvPeriod.WEEKLY, "2026W15", 1, 20); + + assertThat(out.dataSource()).isEqualTo("MV_WEEKLY"); + assertThat(out.totalElements()).isEqualTo(1L); + } + + @Test + @DisplayName("date와 period를 동시에 주면 BAD_REQUEST") + void getRankings_whenDateAndPeriodTogether_shouldThrow() { + assertThatThrownBy(() -> rankingFacade.getRankings( + "20260408", "WEEKLY", "2026W15", 1, 20, Optional.empty())) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("주간 조회에 rankingSnapshotId를 주면 BAD_REQUEST") + void getRankings_whenMvAndSnapshot_shouldThrow() { + assertThatThrownBy(() -> rankingFacade.getRankings( + null, "WEEKLY", "2026W15", 1, 20, Optional.of(UUID.randomUUID().toString()))) + .isInstanceOf(CoreException.class); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java new file mode 100644 index 000000000..66a2a92e7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.ranking; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RankingMvRequestValidatorTest { + + @Test + @DisplayName("period만 있고 periodKey가 없으면 BAD_REQUEST") + void validateMvPairPresent_whenOnlyPeriod_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validateMvPairPresent(true, false)) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("periodKey만 있으면 BAD_REQUEST") + void validateMvPairPresent_whenOnlyPeriodKey_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validateMvPairPresent(false, true)) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("MV 요청과 date 동시 지정이면 BAD_REQUEST") + void validateMutualExclusion_whenBoth_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validateMutualExclusion( + Optional.of("20260408"), true)) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("유효한 주간 periodKey는 통과") + void validatePeriodKey_weeklyOk() { + assertThatCode(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.WEEKLY, "2026W15")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("유효한 월간 periodKey는 통과") + void validatePeriodKey_monthlyOk() { + assertThatCode(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.MONTHLY, "202604")) + .doesNotThrowAnyException(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java index 1bb9a4f65..34c44ea97 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.time.Duration; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -46,6 +47,9 @@ class RankingQueryServiceTest { @Mock private RankingSnapshotRepository rankingSnapshotRepository; + @Mock + private RankingMvReadRepository rankingMvReadRepository; + @Mock private ProductRepository productRepository; @@ -66,6 +70,7 @@ private RankingQueryService newService(boolean fallbackOnRedisFailure) { return new RankingQueryService( rankingReadRepository, rankingSnapshotRepository, + rankingMvReadRepository, productRepository, brandService, likeService, @@ -603,4 +608,74 @@ void loadPage_withSnapshot_whenRedisCountFails_shouldNotFallbackToDb() { assertThat(result.listSource()).isEqualTo(RankingListSource.DEGRADED_EMPTY); assertThat(result.rows()).isEmpty(); } + + @Test + @DisplayName("loadMvPage: 주간 MV 행 순서·Hydration") + void loadMvPage_weekly_shouldHydrateRows() { + when(rankingMvReadRepository.findWeeklyByPeriodKeyOrdered("2026W15")) + .thenReturn(List.of( + new RankingMvTableRow(1, 101L, new BigDecimal("1.5")), + new RankingMvTableRow(2, 102L, new BigDecimal("0.5")) + )); + + ProductModel p101 = mock(ProductModel.class); + when(p101.getBrandId()).thenReturn(1L); + when(p101.getName()).thenReturn("A"); + when(p101.getPrice()).thenReturn(new BigDecimal("1000")); + when(p101.getStockQuantity()).thenReturn(3); + ProductModel p102 = mock(ProductModel.class); + when(p102.getBrandId()).thenReturn(1L); + when(p102.getName()).thenReturn("B"); + when(p102.getPrice()).thenReturn(new BigDecimal("2000")); + when(p102.getStockQuantity()).thenReturn(0); + when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())) + .thenReturn(Map.of(101L, p101, 102L, p102)); + BrandModel brand = mock(BrandModel.class); + when(brand.getName()).thenReturn("브랜드"); + when(brandService.findByIdAndNotDeletedIn(anyCollection())).thenReturn(Map.of(1L, brand)); + when(likeService.getLikeCountByProductIdsFromStats(anyCollection())) + .thenReturn(Map.of(101L, 1L, 102L, 2L)); + + RankingPage result = rankingQueryService.loadMvPage( + RankingMvPeriod.WEEKLY, "2026W15", 1, 10); + + assertThat(result.listSource()).isEqualTo(RankingListSource.MV_WEEKLY); + assertThat(result.totalElements()).isEqualTo(2L); + assertThat(result.rows()).hasSize(2); + assertThat(result.rows().get(0).rank()).isEqualTo(1); + assertThat(result.rows().get(0).productId()).isEqualTo(101L); + assertThat(result.rows().get(0).score()).isEqualTo(1.5d); + assertThat(result.rankingSnapshotId()).isNull(); + } + + @Test + @DisplayName("loadMvPage: page가 총 행을 넘으면 빈 목록·total 유지") + void loadMvPage_whenPageBeyond_shouldReturnEmptyWithTotal() { + when(rankingMvReadRepository.findMonthlyByPeriodKeyOrdered("202604")) + .thenReturn(List.of(new RankingMvTableRow(1, 1L, BigDecimal.ONE))); + + RankingPage result = rankingQueryService.loadMvPage( + RankingMvPeriod.MONTHLY, "202604", 3, 1); + + assertThat(result.listSource()).isEqualTo(RankingListSource.MV_MONTHLY); + assertThat(result.totalElements()).isEqualTo(1L); + assertThat(result.rows()).isEmpty(); + assertThat(result.totalPages()).isEqualTo(1); + } + + @Test + @DisplayName("loadMvPage: 100행 초과 시 total은 100으로 캡") + void loadMvPage_whenMoreThan100Rows_shouldCapTotal() { + List many = new ArrayList<>(); + for (int i = 1; i <= 105; i++) { + many.add(new RankingMvTableRow(i, (long) i, BigDecimal.valueOf(100 - i))); + } + when(rankingMvReadRepository.findWeeklyByPeriodKeyOrdered("2026W01")).thenReturn(many); + when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())).thenReturn(Map.of()); + + RankingPage result = rankingQueryService.loadMvPage( + RankingMvPeriod.WEEKLY, "2026W01", 1, 100); + + assertThat(result.totalElements()).isEqualTo(100L); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java index 3fcfb95cd..3278248ec 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -21,9 +21,12 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.data.redis.core.RedisTemplate; import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; import static com.loopers.interfaces.api.ApiResponse.Metadata.Result; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +57,9 @@ class RankingV1ApiE2ETest { @Autowired private ProductService productService; + @Autowired + private JdbcTemplate jdbcTemplate; + private Long highScoreProductId; private Long lowScoreProductId; private String brandName; @@ -375,4 +381,154 @@ void getRankings_withSnapshot_shouldKeepOrderWhenLiveZsetChanges() { .isEqualTo(p3.getId()) ); } + + @Test + @DisplayName("GET /api/v1/rankings - 주간 MV: DB 행 순·Hydration·MV_WEEKLY") + void getRankings_weeklyMv_shouldReturnFromMaterializedView() { + seedTwoProductRanking(); + String periodKey = "2026W15"; + Instant at = Instant.now(); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + highScoreProductId, + 1, + new BigDecimal("0.90"), + 1, + Timestamp.from(at)); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + lowScoreProductId, + 2, + new BigDecimal("0.30"), + 1, + Timestamp.from(at)); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE)) + .isEqualTo("MV_WEEKLY"), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_WEEKLY"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2L), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().content().get(0).rank()).isEqualTo(1), + () -> assertThat(response.getBody().data().content().get(0).productId()) + .isEqualTo(highScoreProductId), + () -> assertThat(response.getBody().data().content().get(0).score()).isEqualTo(0.9d), + () -> assertThat(response.getBody().data().content().get(1).productId()) + .isEqualTo(lowScoreProductId) + ); + } + + @Test + @DisplayName("GET /api/v1/rankings - 월간 MV·MV_MONTHLY") + void getRankings_monthlyMv_shouldReturnFromMaterializedView() { + seedTwoProductRanking(); + String periodKey = "202604"; + Instant at = Instant.now(); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_monthly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + lowScoreProductId, + 1, + new BigDecimal("0.50"), + 1, + Timestamp.from(at)); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=MONTHLY&periodKey=" + periodKey + "&page=1&size=10", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_MONTHLY"), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).productId()) + .isEqualTo(lowScoreProductId) + ); + } + + @Test + @DisplayName("GET /api/v1/rankings - MV에서 마지막 페이지 초과 시 빈 content·total 유지") + void getRankings_weeklyMv_whenPageBeyond_shouldKeepTotal() { + seedTwoProductRanking(); + String periodKey = "2026W16"; + Instant at = Instant.now(); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + highScoreProductId, + 1, + BigDecimal.ONE, + 1, + Timestamp.from(at)); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=5&size=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1) + ); + } + + @Test + @DisplayName("GET /api/v1/rankings - period만 주면 400") + void getRankings_whenPeriodWithoutKey_shouldReturn400() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=WEEKLY&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().meta().result()).isEqualTo(Result.FAIL); + } + + @Test + @DisplayName("GET /api/v1/rankings - date와 period 동시 지정 시 400") + void getRankings_whenDateWithMvPeriod_shouldReturn400() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?date=20260408&period=WEEKLY&periodKey=2026W15&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java index 9306eb2c7..6b50eda56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java @@ -38,10 +38,10 @@ void getRankings_whenFallbackLatest_shouldExposeSameDataSourceInHeaderAndBody() "FALLBACK_LATEST", null ); - when(rankingFacade.getRankings("20260408", 1, 20, Optional.empty())).thenReturn(fallback); + when(rankingFacade.getRankings("20260408", null, null, 1, 20, Optional.empty())).thenReturn(fallback); ResponseEntity> response = - rankingV1Controller.getRankings("20260408", 1, 20, null); + rankingV1Controller.getRankings("20260408", null, null, 1, 20, null); assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE)) .isEqualTo("FALLBACK_LATEST"); @@ -61,10 +61,10 @@ void getRankings_whenDegraded_shouldExposeSameDataSourceInHeaderAndBody() { "DEGRADED", null ); - when(rankingFacade.getRankings(null, 1, 20, Optional.empty())).thenReturn(degraded); + when(rankingFacade.getRankings(null, null, null, 1, 20, Optional.empty())).thenReturn(degraded); ResponseEntity> response = - rankingV1Controller.getRankings(null, 1, 20, null); + rankingV1Controller.getRankings(null, null, null, 1, 20, null); assertThat(response.getHeaders().getFirst(RankingV1Controller.HEADER_RANKING_DATA_SOURCE)) .isEqualTo("DEGRADED"); diff --git a/http/commerce-api/ranking-v1.http b/http/commerce-api/ranking-v1.http index a1b686958..a3be4829d 100644 --- a/http/commerce-api/ranking-v1.http +++ b/http/commerce-api/ranking-v1.http @@ -25,3 +25,12 @@ POST http://localhost:8080/api/v1/rankings/snapshots?date=20260408 ### 스냅샷으로 페이징 (위에서 받은 UUID로 교체) GET http://localhost:8080/api/v1/rankings?date=20260408&page=1&size=20&rankingSnapshotId=00000000-0000-0000-0000-000000000000 + +### 주간 랭킹 MV (periodKey: yyyyWww, dataSource: MV_WEEKLY) +GET http://localhost:8080/api/v1/rankings?period=WEEKLY&periodKey=2026W15&page=1&size=20 + +### 월간 랭킹 MV (periodKey: yyyyMM, dataSource: MV_MONTHLY) +GET http://localhost:8080/api/v1/rankings?period=MONTHLY&periodKey=202604&page=1&size=20 + +### period만 주고 periodKey 누락 (400 기대) +GET http://localhost:8080/api/v1/rankings?period=WEEKLY&page=1&size=20 From 021941af92032e3863f1b4ef4caa6db76356a90e Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 14:36:29 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20MV=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=84=EC=A0=84=20=EA=B4=80=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingListInfo, RankingPage, RankingQueryService, 및 관련 DTO에 mvPublishVersion 필드를 추가하여 주간 및 월간 MV의 배치 버전을 관리. - RankingMvReadRepository 및 구현체에 최대 버전 조회 메서드를 추가하여 요청 시 활성 버전 정보를 제공. - MV 데이터 조회 로직을 수정하여 최대 버전만을 사용하도록 개선. - 관련 테스트 케이스를 추가하여 새로운 기능의 동작을 검증. --- .../application/ranking/RankingListInfo.java | 7 +- .../ranking/RankingMvReadRepository.java | 11 ++- .../loopers/domain/ranking/RankingPage.java | 4 +- .../domain/ranking/RankingQueryService.java | 46 ++++++--- .../mv/MvProductRankMonthlyJpaRepository.java | 12 ++- .../mv/MvProductRankWeeklyJpaRepository.java | 12 ++- .../mv/RankingMvReadRepositoryImpl.java | 27 +++++- .../interfaces/api/ranking/RankingV1Dto.java | 7 +- .../ranking/RankingFacadeTest.java | 5 +- .../ranking/RankingQueryServiceTest.java | 24 ++++- .../api/ranking/RankingV1ApiE2ETest.java | 57 ++++++++++- .../api/ranking/RankingV1ControllerTest.java | 2 + .../ranking/job/RankingBatchJobConfig.java | 6 +- .../metrics/RankingBatchJobMetrics.java | 72 ++++++++++++++ .../RankingBatchJobMetricsListener.java | 69 ++++++++++++++ .../RankingBatchJobMetricsListenerTest.java | 94 +++++++++++++++++++ .../metrics/RankingBatchJobMetricsTest.java | 23 +++++ 17 files changed, 444 insertions(+), 34 deletions(-) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java index 6788020c2..b4f750388 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingListInfo.java @@ -15,6 +15,7 @@ * @param totalPages 총 페이지 수 * @param dataSource 목록 생성 경로(API {@code dataSource} 문자열과 동일 의미) * @param rankingSnapshotId 스냅샷 조회 시 echo, 라이브면 null + * @param mvPublishVersion 주간/월간 MV의 배치 publish 버전, 일간이면 null */ public record RankingListInfo( List items, @@ -23,7 +24,8 @@ public record RankingListInfo( long totalElements, int totalPages, String dataSource, - String rankingSnapshotId + String rankingSnapshotId, + Integer mvPublishVersion ) { public static RankingListInfo from(RankingPage page) { List items = page.rows().stream() @@ -36,7 +38,8 @@ public static RankingListInfo from(RankingPage page) { page.totalElements(), page.totalPages(), toDataSource(page.listSource()), - page.rankingSnapshotId() + page.rankingSnapshotId(), + page.mvPublishVersion() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java index f637838ac..cdbc3de30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingMvReadRepository.java @@ -1,13 +1,20 @@ package com.loopers.domain.ranking; import java.util.List; +import java.util.Optional; /** * 주간/월간 랭킹 MV 읽기 포트. 구현은 infrastructure. + *

+ * 조회는 요청당 {@code MAX(version)}을 한 번 정한 뒤 동일 버전 행만 읽는다(로드맵 3.6 active version 고정). */ public interface RankingMvReadRepository { - List findWeeklyByPeriodKeyOrdered(String periodKey); + Optional findMaxVersionForWeekly(String periodKey); - List findMonthlyByPeriodKeyOrdered(String periodKey); + Optional findMaxVersionForMonthly(String periodKey); + + List findWeeklyByPeriodKeyAndVersionOrdered(String periodKey, int version); + + List findMonthlyByPeriodKeyAndVersionOrdered(String periodKey, int version); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java index 6a616b50e..27216867f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPage.java @@ -12,6 +12,7 @@ * @param totalPages 총 페이지 수 (1부터) * @param listSource 목록 생성 경로(Redis / DB fallback / degraded) * @param rankingSnapshotId 스냅샷 조회 시 발급·요청한 UUID, 라이브 조회면 null + * @param mvPublishVersion 주간/월간 MV 조회 시 요청 시작 시점의 활성 버전(동일 요청 내 total·rows 일관성), 일간이면 null */ public record RankingPage( List rows, @@ -20,6 +21,7 @@ public record RankingPage( long totalElements, int totalPages, RankingListSource listSource, - String rankingSnapshotId + String rankingSnapshotId, + Integer mvPublishVersion ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java index f8ab16b7a..68eeb19c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingQueryService.java @@ -91,20 +91,31 @@ public RankingPage loadMvPage( int pageOneBased, int size) { validatePageAndSize(pageOneBased, size); - List all = switch (period) { - case WEEKLY -> rankingMvReadRepository.findWeeklyByPeriodKeyOrdered(periodKey); - case MONTHLY -> rankingMvReadRepository.findMonthlyByPeriodKeyOrdered(periodKey); - }; RankingListSource listSource = switch (period) { case WEEKLY -> RankingListSource.MV_WEEKLY; case MONTHLY -> RankingListSource.MV_MONTHLY; }; + Optional maxVersion = switch (period) { + case WEEKLY -> rankingMvReadRepository.findMaxVersionForWeekly(periodKey); + case MONTHLY -> rankingMvReadRepository.findMaxVersionForMonthly(periodKey); + }; + if (maxVersion.isEmpty()) { + return new RankingPage( + List.of(), pageOneBased, size, 0L, 0, listSource, null, null); + } + int activeVersion = maxVersion.get(); + List all = switch (period) { + case WEEKLY -> rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered( + periodKey, activeVersion); + case MONTHLY -> rankingMvReadRepository.findMonthlyByPeriodKeyAndVersionOrdered( + periodKey, activeVersion); + }; List capped = all.stream().limit(100).toList(); long total = capped.size(); int totalPages = computeTotalPages(total, size); if (total == 0L) { return new RankingPage( - List.of(), pageOneBased, size, 0L, 0, listSource, null); + List.of(), pageOneBased, size, 0L, 0, listSource, null, activeVersion); } long startIndex = (long) (pageOneBased - 1) * size; if (startIndex >= total) { @@ -117,13 +128,14 @@ public RankingPage loadMvPage( pageOneBased, size); return new RankingPage( - List.of(), pageOneBased, size, total, totalPages, listSource, null); + List.of(), pageOneBased, size, total, totalPages, listSource, null, activeVersion); } int from = (int) startIndex; int to = (int) Math.min(startIndex + size, total); List slice = capped.subList(from, to); List rows = hydrateMvRows(slice); - return new RankingPage(rows, pageOneBased, size, total, totalPages, listSource, null); + return new RankingPage( + rows, pageOneBased, size, total, totalPages, listSource, null, activeVersion); } /** @@ -310,12 +322,12 @@ private RankingPage loadPageFromZsetKey( int totalPages = computeTotalPages(total, size); if (total == 0L) { return new RankingPage( - List.of(), pageOneBased, size, 0L, 0, listSource, rankingSnapshotIdEcho); + List.of(), pageOneBased, size, 0L, 0, listSource, rankingSnapshotIdEcho, null); } long startIndex = (long) (pageOneBased - 1) * size; if (startIndex >= total) { return new RankingPage( - List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho); + List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null); } long endIndex = Math.min(startIndex + size - 1, total - 1); List entries; @@ -334,7 +346,7 @@ private RankingPage loadPageFromZsetKey( } if (parsedIds.isEmpty()) { return new RankingPage( - List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho); + List.of(), pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null); } Map productMap = productRepository.findByIdInAndNotDeletedAsMap(parsedIds); @@ -379,7 +391,8 @@ private RankingPage loadPageFromZsetKey( product.getStockQuantity() )); } - return new RankingPage(rows, pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho); + return new RankingPage( + rows, pageOneBased, size, total, totalPages, listSource, rankingSnapshotIdEcho, null); } private RankingPage onRedisFailure( @@ -398,7 +411,14 @@ private RankingPage onRedisFailure( operation, key, ex.getClass().getSimpleName(), ex.getMessage()); if (!allowDbFallbackOnRedisFailure || !fallbackOnRedisFailure) { return new RankingPage( - List.of(), pageOneBased, size, 0L, 0, RankingListSource.DEGRADED_EMPTY, rankingSnapshotIdEcho); + List.of(), + pageOneBased, + size, + 0L, + 0, + RankingListSource.DEGRADED_EMPTY, + rankingSnapshotIdEcho, + null); } return loadPageFromLatestProducts(pageOneBased, size); } finally { @@ -425,6 +445,7 @@ private RankingPage loadPageFromLatestProducts(int pageOneBased, int size) { productPage.getTotalElements(), productPage.getTotalPages(), RankingListSource.DEGRADED_EMPTY, + null, null ); } @@ -467,6 +488,7 @@ private RankingPage loadPageFromLatestProducts(int pageOneBased, int size) { productPage.getTotalElements(), productPage.getTotalPages(), RankingListSource.FALLBACK_DB_LATEST, + null, null ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java index 60dcf82d3..1f7a8ea60 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankMonthlyJpaRepository.java @@ -1,10 +1,20 @@ package com.loopers.infrastructure.ranking.mv; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; public interface MvProductRankMonthlyJpaRepository extends JpaRepository { - List findByPeriodKeyOrderByRankValueAsc(String periodKey); + /** + * 주간 랭킹 MV 최대 버전을 조회한다. + * + * @param periodKey 기간 키 + * @return 주간 랭킹 MV 최대 버전 + */ + @Query("select max(e.version) from MvProductRankMonthlyEntity e where e.periodKey = ?1") + Integer findMaxVersionByPeriodKey(String periodKey); + + List findByPeriodKeyAndVersionOrderByRankValueAsc(String periodKey, int version); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java index e5f83456e..9a3574865 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MvProductRankWeeklyJpaRepository.java @@ -1,10 +1,20 @@ package com.loopers.infrastructure.ranking.mv; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import java.util.List; public interface MvProductRankWeeklyJpaRepository extends JpaRepository { - List findByPeriodKeyOrderByRankValueAsc(String periodKey); + /** + * 주간 랭킹 MV 최대 버전을 조회한다. + * + * @param periodKey 기간 키 + * @return 주간 랭킹 MV 최대 버전 + */ + @Query("select max(e.version) from MvProductRankWeeklyEntity e where e.periodKey = ?1") + Integer findMaxVersionByPeriodKey(String periodKey); + + List findByPeriodKeyAndVersionOrderByRankValueAsc(String periodKey, int version); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java index 76be9e360..599051d96 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/RankingMvReadRepositoryImpl.java @@ -5,6 +5,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public class RankingMvReadRepositoryImpl implements RankingMvReadRepository { @@ -19,15 +20,32 @@ public RankingMvReadRepositoryImpl( this.monthlyJpaRepository = monthlyJpaRepository; } + /** + * 주간 랭킹 MV 최대 버전을 조회한다. + * + * @param periodKey 기간 키 + * @return 주간 랭킹 MV 최대 버전 + */ + @Override + public Optional findMaxVersionForWeekly(String periodKey) { + return Optional.ofNullable(weeklyJpaRepository.findMaxVersionByPeriodKey(periodKey)); + } + + @Override + public Optional findMaxVersionForMonthly(String periodKey) { + return Optional.ofNullable(monthlyJpaRepository.findMaxVersionByPeriodKey(periodKey)); + } + /** * 주간 랭킹 MV를 조회한다. * * @param periodKey 기간 키 + * @param version 버전 * @return 주간 랭킹 MV */ @Override - public List findWeeklyByPeriodKeyOrdered(String periodKey) { - return weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + public List findWeeklyByPeriodKeyAndVersionOrdered(String periodKey, int version) { + return weeklyJpaRepository.findByPeriodKeyAndVersionOrderByRankValueAsc(periodKey, version).stream() .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore())) .toList(); } @@ -36,11 +54,12 @@ public List findWeeklyByPeriodKeyOrdered(String periodKey) { * 월간 랭킹 MV를 조회한다. * * @param periodKey 기간 키 + * @param version 버전 * @return 월간 랭킹 MV */ @Override - public List findMonthlyByPeriodKeyOrdered(String periodKey) { - return monthlyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey).stream() + public List findMonthlyByPeriodKeyAndVersionOrdered(String periodKey, int version) { + return monthlyJpaRepository.findByPeriodKeyAndVersionOrderByRankValueAsc(periodKey, version).stream() .map(e -> new RankingMvTableRow(e.getRankValue(), e.getProductId(), e.getScore())) .toList(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java index 5b4dbefba..826af97fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -72,7 +72,9 @@ public record ListResponse( @Schema(description = "REDIS·REDIS_SNAPSHOT·FALLBACK_LATEST·DEGRADED·MV_WEEKLY·MV_MONTHLY") String dataSource, @Schema(description = "스냅샷 조회 시 echo, 라이브 조회면 null") - String rankingSnapshotId + String rankingSnapshotId, + @Schema(description = "주간/월간 MV publish 버전(요청당 고정). 일간 조회면 null") + Integer mvPublishVersion ) { /** * 랭킹 목록 결과를 응답 DTO로 변환한다. @@ -96,7 +98,8 @@ public static ListResponse from(RankingListInfo result) { result.totalElements(), result.totalPages(), result.dataSource(), - result.rankingSnapshotId() + result.rankingSnapshotId(), + result.mvPublishVersion() ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java index cd3369ddb..e0b51ef98 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java @@ -47,6 +47,7 @@ void getRankings_shouldDelegateWithParsedDate() { 1L, 1, RankingListSource.REDIS_ZSET, + null, null ); when(rankingQueryService.loadPage(eq(LocalDate.of(2026, 4, 8)), eq(1), eq(20), eq(Optional.empty()))) @@ -78,7 +79,8 @@ void getRankings_whenWeeklyMv_shouldDelegateLoadMvPage() { 1L, 1, RankingListSource.MV_WEEKLY, - null + null, + 1 ); when(rankingQueryService.loadMvPage(RankingMvPeriod.WEEKLY, "2026W15", 1, 20)) .thenReturn(domainPage); @@ -90,6 +92,7 @@ void getRankings_whenWeeklyMv_shouldDelegateLoadMvPage() { assertThat(out.dataSource()).isEqualTo("MV_WEEKLY"); assertThat(out.totalElements()).isEqualTo(1L); + assertThat(out.mvPublishVersion()).isEqualTo(1); } @Test diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java index 34c44ea97..381886582 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java @@ -612,7 +612,8 @@ void loadPage_withSnapshot_whenRedisCountFails_shouldNotFallbackToDb() { @Test @DisplayName("loadMvPage: 주간 MV 행 순서·Hydration") void loadMvPage_weekly_shouldHydrateRows() { - when(rankingMvReadRepository.findWeeklyByPeriodKeyOrdered("2026W15")) + when(rankingMvReadRepository.findMaxVersionForWeekly("2026W15")).thenReturn(Optional.of(1)); + when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W15", 1)) .thenReturn(List.of( new RankingMvTableRow(1, 101L, new BigDecimal("1.5")), new RankingMvTableRow(2, 102L, new BigDecimal("0.5")) @@ -646,12 +647,14 @@ void loadMvPage_weekly_shouldHydrateRows() { assertThat(result.rows().get(0).productId()).isEqualTo(101L); assertThat(result.rows().get(0).score()).isEqualTo(1.5d); assertThat(result.rankingSnapshotId()).isNull(); + assertThat(result.mvPublishVersion()).isEqualTo(1); } @Test @DisplayName("loadMvPage: page가 총 행을 넘으면 빈 목록·total 유지") void loadMvPage_whenPageBeyond_shouldReturnEmptyWithTotal() { - when(rankingMvReadRepository.findMonthlyByPeriodKeyOrdered("202604")) + when(rankingMvReadRepository.findMaxVersionForMonthly("202604")).thenReturn(Optional.of(1)); + when(rankingMvReadRepository.findMonthlyByPeriodKeyAndVersionOrdered("202604", 1)) .thenReturn(List.of(new RankingMvTableRow(1, 1L, BigDecimal.ONE))); RankingPage result = rankingQueryService.loadMvPage( @@ -661,6 +664,7 @@ void loadMvPage_whenPageBeyond_shouldReturnEmptyWithTotal() { assertThat(result.totalElements()).isEqualTo(1L); assertThat(result.rows()).isEmpty(); assertThat(result.totalPages()).isEqualTo(1); + assertThat(result.mvPublishVersion()).isEqualTo(1); } @Test @@ -670,12 +674,26 @@ void loadMvPage_whenMoreThan100Rows_shouldCapTotal() { for (int i = 1; i <= 105; i++) { many.add(new RankingMvTableRow(i, (long) i, BigDecimal.valueOf(100 - i))); } - when(rankingMvReadRepository.findWeeklyByPeriodKeyOrdered("2026W01")).thenReturn(many); + when(rankingMvReadRepository.findMaxVersionForWeekly("2026W01")).thenReturn(Optional.of(2)); + when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W01", 2)).thenReturn(many); when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())).thenReturn(Map.of()); RankingPage result = rankingQueryService.loadMvPage( RankingMvPeriod.WEEKLY, "2026W01", 1, 100); assertThat(result.totalElements()).isEqualTo(100L); + assertThat(result.mvPublishVersion()).isEqualTo(2); + } + + @Test + @DisplayName("loadMvPage: MAX(version)이 없으면 빈 페이지") + void loadMvPage_whenNoVersion_shouldReturnEmpty() { + when(rankingMvReadRepository.findMaxVersionForWeekly("2026W99")).thenReturn(Optional.empty()); + + RankingPage result = rankingQueryService.loadMvPage( + RankingMvPeriod.WEEKLY, "2026W99", 1, 10); + + assertThat(result.totalElements()).isEqualTo(0L); + assertThat(result.mvPublishVersion()).isNull(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java index 3278248ec..e7dfd2497 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -432,7 +432,8 @@ void getRankings_weeklyMv_shouldReturnFromMaterializedView() { .isEqualTo(highScoreProductId), () -> assertThat(response.getBody().data().content().get(0).score()).isEqualTo(0.9d), () -> assertThat(response.getBody().data().content().get(1).productId()) - .isEqualTo(lowScoreProductId) + .isEqualTo(lowScoreProductId), + () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1) ); } @@ -468,7 +469,56 @@ void getRankings_monthlyMv_shouldReturnFromMaterializedView() { () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), () -> assertThat(response.getBody().data().content()).hasSize(1), () -> assertThat(response.getBody().data().content().get(0).productId()) - .isEqualTo(lowScoreProductId) + .isEqualTo(lowScoreProductId), + () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1) + ); + } + + @Test + @DisplayName("GET /api/v1/rankings - MV는 요청당 MAX(version)만 조회(혼합 버전 시 상위 버전만)") + void getRankings_weeklyMv_whenMixedVersions_shouldUseMaxVersionOnly() { + seedTwoProductRanking(); + String periodKey = "2026W20"; + Instant at = Instant.now(); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + highScoreProductId, + 1, + new BigDecimal("0.10"), + 1, + Timestamp.from(at)); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + lowScoreProductId, + 1, + new BigDecimal("0.99"), + 2, + Timestamp.from(at)); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=WEEKLY&periodKey=" + periodKey + "&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).productId()) + .isEqualTo(lowScoreProductId), + () -> assertThat(response.getBody().data().content().get(0).score()).isEqualTo(0.99d) ); } @@ -502,7 +552,8 @@ void getRankings_weeklyMv_whenPageBeyond_shouldKeepTotal() { () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().content()).isEmpty(), () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), - () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1) + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1), + () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java index 6b50eda56..ecf113017 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerTest.java @@ -36,6 +36,7 @@ void getRankings_whenFallbackLatest_shouldExposeSameDataSourceInHeaderAndBody() 0L, 0, "FALLBACK_LATEST", + null, null ); when(rankingFacade.getRankings("20260408", null, null, 1, 20, Optional.empty())).thenReturn(fallback); @@ -59,6 +60,7 @@ void getRankings_whenDegraded_shouldExposeSameDataSourceInHeaderAndBody() { 0L, 0, "DEGRADED", + null, null ); when(rankingFacade.getRankings(null, null, null, 1, 20, Optional.empty())).thenReturn(degraded); diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java index f10bae1c9..f484afc2d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/job/RankingBatchJobConfig.java @@ -133,7 +133,7 @@ public void validate(JobParameters parameters) throws JobParametersInvalidExcept * @param lock RedisRankingBatchLock * @return JobExecutionListener */ - @Bean + @Bean("rankingBatchLockReleaseListener") public JobExecutionListener rankingBatchLockReleaseListener(RedisRankingBatchLock lock) { return new JobExecutionListener() { @Override @@ -380,7 +380,8 @@ public Tasklet rankingPublishTasklet( @Bean(JOB_NAME) public Job rankingProductMvJob( JobParametersValidator rankingJobParametersValidator, - JobExecutionListener rankingBatchLockReleaseListener, + @Qualifier("rankingBatchLockReleaseListener") JobExecutionListener rankingBatchLockReleaseListener, + @Qualifier("rankingBatchJobMetricsListener") JobExecutionListener rankingBatchJobMetricsListener, @Qualifier(STEP_PERIOD_LOCK) Step periodLockStep, @Qualifier(STEP_STAGING_CLEANUP) Step stagingCleanupStep, @Qualifier(STEP_AGGREGATE) Step aggregateStep, @@ -390,6 +391,7 @@ public Job rankingProductMvJob( .incrementer(new RunIdIncrementer()) .validator(rankingJobParametersValidator) .listener(rankingBatchLockReleaseListener) + .listener(rankingBatchJobMetricsListener) .listener(jobListener) .start(periodLockStep) .next(stagingCleanupStep) diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java new file mode 100644 index 000000000..9968b6b18 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetrics.java @@ -0,0 +1,72 @@ +package com.loopers.batch.ranking.metrics; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 랭킹 MV 배치 성공 시각·스냅샷 노후화(초) 게이지. 로드맵 3.7. + */ +@Component +public class RankingBatchJobMetrics { + + private final MeterRegistry meterRegistry; + private final ConcurrentHashMap lastSuccessEpochSeconds = new ConcurrentHashMap<>(); + + public RankingBatchJobMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + /** + * Job이 COMPLETED일 때 period·periodKey별 마지막 성공 시각(epoch 초)을 갱신한다. + * + * @param period WEEKLY / MONTHLY + * @param periodKey 주간·월간 키 + */ + public void recordSuccess(String period, String periodKey) { + String key = mapKey(period, periodKey); + AtomicLong holder = lastSuccessEpochSeconds.computeIfAbsent(key, k -> registerGauges(period, periodKey)); + holder.set(Instant.now().getEpochSecond()); + } + + /** + * 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지를 등록한다. + * + * @param period 기간 + * @param periodKey 기간 키 + * @return 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지 + */ + private AtomicLong registerGauges(String period, String periodKey) { + AtomicLong holder = new AtomicLong(0L); + Tags tags = Tags.of("period", period, "period_key", periodKey); + Gauge.builder("batch.rank.job.last.success.epoch", holder, h -> (double) h.get()) + .tags(tags) + .register(meterRegistry); + Gauge.builder("batch.rank.snapshot.stale.seconds", holder, RankingBatchJobMetrics::staleSeconds) + .tags(tags) + .register(meterRegistry); + return holder; + } + + /** + * 랭킹 배치 성공 시각·스냅샷 노후화(초) 게이지를 계산한다. + * @param lastSuccessEpoch + * @return + */ + private static double staleSeconds(AtomicLong lastSuccessEpoch) { + long t = lastSuccessEpoch.get(); + if (t <= 0L) { + return -1d; + } + return (double) (Instant.now().getEpochSecond() - t); + } + + private static String mapKey(String period, String periodKey) { + return period + "|" + periodKey; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java new file mode 100644 index 000000000..4e1d7f70c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListener.java @@ -0,0 +1,69 @@ +package com.loopers.batch.ranking.metrics; + +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.stereotype.Component; + +/** + * 랭킹 MV 배치 실패 카운트·성공 시각 메트릭. 로드맵 3.7. + */ +@Component("rankingBatchJobMetricsListener") +public class RankingBatchJobMetricsListener implements JobExecutionListener { + + private final MeterRegistry meterRegistry; + private final RankingBatchJobMetrics rankingBatchJobMetrics; + + /** + * 랭킹 배치 실패 카운트·성공 시각 메트릭 리스너를 생성한다. + * + * @param meterRegistry MeterRegistry + * @param rankingBatchJobMetrics RankingBatchJobMetrics + */ + public RankingBatchJobMetricsListener( + MeterRegistry meterRegistry, + RankingBatchJobMetrics rankingBatchJobMetrics) { + this.meterRegistry = meterRegistry; + this.rankingBatchJobMetrics = rankingBatchJobMetrics; + } + + /** + * 랭킹 배치 실행 전 작업을 수행한다. + * + * @param jobExecution JobExecution + */ + @Override + public void beforeJob(JobExecution jobExecution) { + } + + /** + * 랭킹 배치 실행 후 작업을 수행한다. + * + * @param jobExecution JobExecution + */ + @Override + public void afterJob(JobExecution jobExecution) { + if (!RankingBatchJobParameters.JOB_NAME.equals(jobExecution.getJobInstance().getJobName())) { + return; + } + String period = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD); + String periodKey = jobExecution.getJobParameters().getString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY); + if (period == null || periodKey == null) { + return; + } + BatchStatus status = jobExecution.getStatus(); + if (status == BatchStatus.COMPLETED) { + rankingBatchJobMetrics.recordSuccess(period, periodKey); + return; + } + if (status.isUnsuccessful()) { + Counter.builder("batch.rank.job.failure.count") + .tags("period", period, "period_key", periodKey, "batch_status", status.name()) + .register(meterRegistry) + .increment(); + } + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java new file mode 100644 index 000000000..92e426272 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsListenerTest.java @@ -0,0 +1,94 @@ +package com.loopers.batch.ranking.metrics; + +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RankingBatchJobMetricsListenerTest { + + @Mock + private RankingBatchJobMetrics rankingBatchJobMetrics; + + private final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry(); + + private RankingBatchJobMetricsListener listener; + + @BeforeEach + void setUp() { + listener = new RankingBatchJobMetricsListener(meterRegistry, rankingBatchJobMetrics); + } + + @Test + @DisplayName("COMPLETED이면 recordSuccess 호출") + void afterJob_whenCompleted_shouldRecordSuccess() { + JobExecution execution = mockJobExecution(RankingBatchJobParameters.JOB_NAME, BatchStatus.COMPLETED); + + listener.afterJob(execution); + + verify(rankingBatchJobMetrics).recordSuccess("WEEKLY", "2026W15"); + assertThat(meterRegistry.find("batch.rank.job.failure.count").meters()).isEmpty(); + } + + @Test + @DisplayName("FAILED이면 failure 카운터 증가") + void afterJob_whenFailed_shouldIncrementFailureCounter() { + JobExecution execution = mockJobExecution(RankingBatchJobParameters.JOB_NAME, BatchStatus.FAILED); + + listener.afterJob(execution); + + verify(rankingBatchJobMetrics, never()).recordSuccess(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + assertThat( + meterRegistry.counter( + "batch.rank.job.failure.count", + "period", "WEEKLY", + "period_key", "2026W15", + "batch_status", "FAILED" + ).count() + ).isEqualTo(1.0d); + } + + @Test + @DisplayName("다른 Job이면 메트릭 없음") + void afterJob_whenOtherJob_shouldNoop() { + JobInstance inst = mock(JobInstance.class); + when(inst.getJobName()).thenReturn("otherJob"); + JobExecution execution = mock(JobExecution.class); + when(execution.getJobInstance()).thenReturn(inst); + + listener.afterJob(execution); + + verify(rankingBatchJobMetrics, never()).recordSuccess(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + assertThat(meterRegistry.find("batch.rank.job.failure.count").meters()).isEmpty(); + } + + private static JobExecution mockJobExecution(String jobName, BatchStatus status) { + JobInstance inst = mock(JobInstance.class); + when(inst.getJobName()).thenReturn(jobName); + JobParameters params = new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15") + .toJobParameters(); + JobExecution execution = mock(JobExecution.class); + when(execution.getJobInstance()).thenReturn(inst); + when(execution.getJobParameters()).thenReturn(params); + when(execution.getStatus()).thenReturn(status); + return execution; + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java new file mode 100644 index 000000000..ef8c6b61a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/metrics/RankingBatchJobMetricsTest.java @@ -0,0 +1,23 @@ +package com.loopers.batch.ranking.metrics; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RankingBatchJobMetricsTest { + + @Test + @DisplayName("recordSuccess 시 last.success·stale 게이지 등록") + void recordSuccess_shouldRegisterGauges() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + RankingBatchJobMetrics metrics = new RankingBatchJobMetrics(registry); + + metrics.recordSuccess("WEEKLY", "2026W15"); + + assertThat(registry.find("batch.rank.job.last.success.epoch").gauges()).isNotEmpty(); + assertThat(registry.find("batch.rank.snapshot.stale.seconds").gauges()).isNotEmpty(); + assertThat(registry.find("batch.rank.job.last.success.epoch").gauge().value()).isGreaterThan(0); + } +} From ece9b8f8149ffbc5fe5411edad9343844c326e1b Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 15:21:02 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test:=20=EC=B6=94=EA=B0=80=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EB=9E=AD=ED=82=B9=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=9E=91=EC=97=85=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingMvRequestValidatorTest에 주간 및 월간 기간 키 검증을 위한 테스트 케이스 추가. - RankingV1ApiE2ETest에 월간 MV의 마지막 페이지 초과 시 빈 결과를 유지하는 테스트 추가. - RankingPeriodKeyTest에 주간 기간 키 생성 로직에 대한 추가 테스트 케이스 구현. - RankingBatchJobE2ETest에 동일한 Job을 두 번 실행해도 결과가 동일함을 검증하는 테스트 추가. - RankingPublishFailurePreservesMvIntegrationTest를 새로 추가하여 발행 검증 실패 시 기존 MV 유지 여부를 확인하는 테스트 케이스 구현. - RankingPublishOnlyJobTestConfig를 추가하여 스테이징 검증 실패 시 MV 비교를 위한 Job 설정을 제공. --- .../RankingMvRequestValidatorTest.java | 35 +++++ .../api/ranking/RankingV1ApiE2ETest.java | 35 +++++ .../ProductRankMvPublishRepositoryImpl.java | 9 ++ .../batch/ranking/RankingPeriodKeyTest.java | 17 +++ .../job/ranking/RankingBatchJobE2ETest.java | 50 +++++++ ...lishFailurePreservesMvIntegrationTest.java | 136 ++++++++++++++++++ .../RankingPublishOnlyJobTestConfig.java | 28 ++++ 7 files changed, 310 insertions(+) create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java index 66a2a92e7..928a71ed4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingMvRequestValidatorTest.java @@ -48,4 +48,39 @@ void validatePeriodKey_monthlyOk() { RankingMvPeriod.MONTHLY, "202604")) .doesNotThrowAnyException(); } + + @Test + @DisplayName("주간 periodKey가 yyyyWww 패턴이 아니면 BAD_REQUEST") + void validatePeriodKey_weeklyInvalidFormat_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.WEEKLY, "20260406")) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("주간 periodKey 주차가 01~53 밖이면 BAD_REQUEST") + void validatePeriodKey_weeklyOutOfRangeWeek_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.WEEKLY, "2026W00")) + .isInstanceOf(CoreException.class); + assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.WEEKLY, "2026W54")) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("월간 periodKey가 6자리 숫자가 아니면 BAD_REQUEST") + void validatePeriodKey_monthlyInvalidFormat_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.MONTHLY, "2026-04")) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("월간 periodKey의 월이 01~12 밖이면 BAD_REQUEST") + void validatePeriodKey_monthlyInvalidMonth_shouldThrow() { + assertThatThrownBy(() -> RankingMvRequestValidator.validatePeriodKey( + RankingMvPeriod.MONTHLY, "202613")) + .isInstanceOf(CoreException.class); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java index e7dfd2497..485a2aaca 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -557,6 +557,41 @@ void getRankings_weeklyMv_whenPageBeyond_shouldKeepTotal() { ); } + @Test + @DisplayName("GET /api/v1/rankings - 월간 MV에서 마지막 페이지 초과 시 빈 content·total 유지") + void getRankings_monthlyMv_whenPageBeyond_shouldKeepTotal() { + seedTwoProductRanking(); + String periodKey = "202605"; + Instant at = Instant.now(); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_monthly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + highScoreProductId, + 1, + BigDecimal.ONE, + 1, + Timestamp.from(at)); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=MONTHLY&periodKey=" + periodKey + "&page=5&size=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1L), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(1), + () -> assertThat(response.getBody().data().mvPublishVersion()).isEqualTo(1) + ); + } + @Test @DisplayName("GET /api/v1/rankings - period만 주면 400") void getRankings_whenPeriodWithoutKey_shouldReturn400() { diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java index 892ff0902..04157e57d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/mv/ProductRankMvPublishRepositoryImpl.java @@ -2,6 +2,8 @@ import com.loopers.domain.ranking.mv.ProductRankMvPublishRepository; import com.loopers.domain.ranking.mv.ProductRankMvRow; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -11,6 +13,9 @@ @Repository public class ProductRankMvPublishRepositoryImpl implements ProductRankMvPublishRepository { + @PersistenceContext + private EntityManager entityManager; + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; @@ -33,6 +38,8 @@ public ProductRankMvPublishRepositoryImpl( @Transactional public void replaceWeeklyPeriod(String periodKey, List rows, Instant publishedAt) { weeklyJpaRepository.deleteByPeriodKey(periodKey); + entityManager.flush(); + entityManager.clear(); weeklyJpaRepository.saveAll(rows.stream().map(r -> toWeeklyEntity(r, publishedAt)).toList()); } @@ -47,6 +54,8 @@ public void replaceWeeklyPeriod(String periodKey, List rows, I @Transactional public void replaceMonthlyPeriod(String periodKey, List rows, Instant publishedAt) { monthlyJpaRepository.deleteByPeriodKey(periodKey); + entityManager.flush(); + entityManager.clear(); monthlyJpaRepository.saveAll(rows.stream().map(r -> toMonthlyEntity(r, publishedAt)).toList()); } diff --git a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java index 21dce36c9..b3c5c9efd 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/batch/ranking/RankingPeriodKeyTest.java @@ -26,6 +26,23 @@ void weekly_whenDatesInSameIsoWeek_shouldShareMondayAnchor() { assertThat(key2).isEqualTo("20260406"); } + @Test + @DisplayName("weekly: 2026-01-01(목) 연초는 ISO 주의 월요일 앵커(전년 12/29)로 귀속된다.") + void weekly_whenNewYearsDay2026_shouldMapToMondayAnchorInPriorDecember() { + LocalDate newYears = LocalDate.of(2026, 1, 1); + LocalDate mondaySameIsoWeek = LocalDate.of(2025, 12, 29); + + assertThat(RankingPeriodKey.weekly(newYears)).isEqualTo("20251229"); + assertThat(RankingPeriodKey.weekly(mondaySameIsoWeek)).isEqualTo("20251229"); + } + + @Test + @DisplayName("weekly: 월요일 시작 규칙 — 앵커는 항상 해당 주의 월요일 yyyyMMdd이다.") + void weekly_whenSundayInWeek_shouldStillResolveToMondayOfThatWeek() { + LocalDate sunday = LocalDate.of(2026, 4, 12); + assertThat(RankingPeriodKey.weekly(sunday)).isEqualTo("20260406"); + } + @Test @DisplayName("monthly: yyyyMMdd 형식으로 해당 월의 첫째 날 키를 만든다.") void monthly_whenLocalDateGiven_shouldReturnFirstDayYyyyMmDd() { diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java index 4abcd3a73..9dfad22f2 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingBatchJobE2ETest.java @@ -139,4 +139,54 @@ INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, () -> assertThat(weeklyMv.get(1).getProductId()).isEqualTo(2L) ); } + + @Test + @DisplayName("동일 period·periodKey로 Job을 두 번 실행해도 MV rank·product_id 결과가 동일하다(멱등).") + void launchJob_twiceSamePeriod_shouldProduceSameWeeklyMv() throws Exception { + jobLauncherTestUtils.setJob(job); + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + jdbcTemplate.update( + """ + INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + 1L, 0L, 0L, 10L, java.sql.Timestamp.from(at) + ); + jdbcTemplate.update( + """ + INSERT INTO product_metrics (product_id, like_count, view_count, sold_quantity, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + 2L, 0L, 0L, 5L, java.sql.Timestamp.from(at) + ); + var params1 = new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15") + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + var first = jobLauncherTestUtils.launchJob(params1); + assertThat(first.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + var afterFirst = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15"); + + var params2 = new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, "2026W15") + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters(); + var second = jobLauncherTestUtils.launchJob(params2); + assertThat(second.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + var afterSecond = mvProductRankWeeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc("2026W15"); + + assertAll( + () -> assertThat(afterSecond).hasSize(afterFirst.size()), + () -> assertThat(afterSecond.get(0).getProductId()).isEqualTo(afterFirst.get(0).getProductId()), + () -> assertThat(afterSecond.get(0).getRankValue()).isEqualTo(afterFirst.get(0).getRankValue()), + () -> assertThat(afterSecond.get(0).getScore()).isEqualByComparingTo(afterFirst.get(0).getScore()), + () -> assertThat(afterSecond.get(1).getProductId()).isEqualTo(afterFirst.get(1).getProductId()), + () -> assertThat(afterSecond.get(1).getRankValue()).isEqualTo(afterFirst.get(1).getRankValue()), + () -> assertThat(afterSecond.get(1).getScore()).isEqualByComparingTo(afterFirst.get(1).getScore()) + ); + } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java new file mode 100644 index 000000000..c5296cb08 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishFailurePreservesMvIntegrationTest.java @@ -0,0 +1,136 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.ranking.job.RankingBatchJobConfig; +import com.loopers.domain.ranking.batch.RankingBatchJobParameters; +import com.loopers.domain.ranking.batch.RankingStagingRankRow; +import com.loopers.domain.ranking.batch.RankingStagingRepository; +import com.loopers.infrastructure.ranking.mv.MvProductRankWeeklyJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.testcontainers.RedisTestContainersConfig; +import com.loopers.utils.RedisCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(properties = { + "spring.batch.job.enabled=false", + "outbox.relay.enabled=false" +}) +@TestPropertySource(properties = "spring.batch.job.name=" + RankingBatchJobConfig.JOB_NAME) +@Import({ + MySqlTestContainersConfig.class, + RedisTestContainersConfig.class, + RankingPublishOnlyJobTestConfig.class +}) +class RankingPublishFailurePreservesMvIntegrationTest { + + private static final List TRUNCATE_TABLES = List.of( + "mv_product_rank_staging", + "mv_product_rank_weekly", + "mv_product_rank_monthly", + "product_metrics", + "outbox_event" + ); + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + @Qualifier(RankingPublishOnlyJobTestConfig.BEAN_NAME) + private Job publishOnlyJob; + + @Autowired + private RankingStagingRepository rankingStagingRepository; + + @Autowired + private MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private RedisCleanUp redisCleanUp; + + @BeforeEach + void cleanDb() { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + for (String table : TRUNCATE_TABLES) { + jdbcTemplate.execute("TRUNCATE TABLE `" + table + "`"); + } + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + + @AfterEach + void tearDown() { + redisCleanUp.truncateAll(); + } + + @Test + @DisplayName("publish 검증 실패 시 기존 주간 MV 행이 유지된다(half-written 비노출).") + void launchPublishOnly_whenStagingInvalid_shouldFailWithoutReplacingMv() throws Exception { + String periodKey = "2026W15"; + Instant at = Instant.parse("2026-04-10T00:00:00Z"); + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (period_key, product_id, `rank`, score, version, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + periodKey, + 99L, + 1, + new BigDecimal("0.50"), + 1, + Timestamp.from(at) + ); + + rankingStagingRepository.deleteByPeriodTypeAndPeriodKey("WEEKLY", periodKey); + rankingStagingRepository.saveRankedRows( + "WEEKLY", + periodKey, + List.of( + new RankingStagingRankRow(1, 1L, BigDecimal.ONE), + new RankingStagingRankRow(3, 2L, BigDecimal.TEN) + ) + ); + + JobExecution execution = jobLauncher.run( + publishOnlyJob, + new JobParametersBuilder() + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD, "WEEKLY") + .addString(RankingBatchJobParameters.JOB_PARAM_PERIOD_KEY, periodKey) + .addLong("run.id", System.currentTimeMillis()) + .toJobParameters() + ); + + var rows = weeklyJpaRepository.findByPeriodKeyOrderByRankValueAsc(periodKey); + + assertAll( + () -> assertThat(execution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()), + () -> assertThat(rows).hasSize(1), + () -> assertThat(rows.get(0).getProductId()).isEqualTo(99L), + () -> assertThat(rows.get(0).getRankValue()).isEqualTo(1), + () -> assertThat(rows.get(0).getScore()).isEqualByComparingTo(new BigDecimal("0.50")) + ); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java new file mode 100644 index 000000000..d028bb72c --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingPublishOnlyJobTestConfig.java @@ -0,0 +1,28 @@ +package com.loopers.job.ranking; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.Step; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * 스테이징 검증 실패 시 MV 비교를 위해 publish Step 단독 Job을 테스트에서 실행한다. + */ +@TestConfiguration +public class RankingPublishOnlyJobTestConfig { + + public static final String BEAN_NAME = "rankingPublishOnlyJob"; + + @Bean(BEAN_NAME) + public Job rankingPublishOnlyJob( + JobRepository jobRepository, + @Qualifier("rankingPublish") Step rankingPublishStep + ) { + return new JobBuilder("rankingPublishOnlyJob", jobRepository) + .start(rankingPublishStep) + .build(); + } +} From 3b779e6dd25bb26b7607602a11029291a33ebedc Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 16:36:28 +0900 Subject: [PATCH 09/10] =?UTF-8?q?test:=20=EC=B6=94=EA=B0=80=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20MV=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingQueryServiceTest에 고정된 버전 사용을 검증하는 테스트 케이스 추가. - RankingV1ApiE2ETest에 주간 MV 버전이 없을 때 빈 결과와 null 버전 반환을 검증하는 테스트 케이스 추가. --- .../ranking/RankingQueryServiceTest.java | 31 +++++++++++++++++++ .../api/ranking/RankingV1ApiE2ETest.java | 19 ++++++++++++ 2 files changed, 50 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java index 381886582..da87b5fe2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingQueryServiceTest.java @@ -36,6 +36,9 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -650,6 +653,34 @@ void loadMvPage_weekly_shouldHydrateRows() { assertThat(result.mvPublishVersion()).isEqualTo(1); } + @Test + @DisplayName("loadMvPage: 조회 중 더 높은 버전이 생겨도 요청 시작 시 고정한 버전만 사용한다.") + void loadMvPage_whenHigherVersionExistsAfterFixedSelection_shouldUseOnlyFixedVersion() { + when(rankingMvReadRepository.findMaxVersionForWeekly("2026W30")).thenReturn(Optional.of(1)); + when(rankingMvReadRepository.findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 1)) + .thenReturn(List.of(new RankingMvTableRow(1, 101L, BigDecimal.TEN))); + + ProductModel p101 = mock(ProductModel.class); + when(p101.getBrandId()).thenReturn(1L); + when(p101.getName()).thenReturn("A"); + when(p101.getPrice()).thenReturn(new BigDecimal("1000")); + when(p101.getStockQuantity()).thenReturn(1); + when(productRepository.findByIdInAndNotDeletedAsMap(anyCollection())).thenReturn(Map.of(101L, p101)); + BrandModel brand = mock(BrandModel.class); + when(brand.getName()).thenReturn("브랜드"); + when(brandService.findByIdAndNotDeletedIn(anyCollection())).thenReturn(Map.of(1L, brand)); + when(likeService.getLikeCountByProductIdsFromStats(anyCollection())).thenReturn(Map.of()); + + RankingPage result = rankingQueryService.loadMvPage( + RankingMvPeriod.WEEKLY, "2026W30", 1, 20); + + assertThat(result.mvPublishVersion()).isEqualTo(1); + assertThat(result.rows()).hasSize(1); + assertThat(result.rows().get(0).productId()).isEqualTo(101L); + verify(rankingMvReadRepository, times(1)).findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 1); + verify(rankingMvReadRepository, never()).findWeeklyByPeriodKeyAndVersionOrdered("2026W30", 2); + } + @Test @DisplayName("loadMvPage: page가 총 행을 넘으면 빈 목록·total 유지") void loadMvPage_whenPageBeyond_shouldReturnEmptyWithTotal() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java index 485a2aaca..83d624668 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ApiE2ETest.java @@ -592,6 +592,25 @@ void getRankings_monthlyMv_whenPageBeyond_shouldKeepTotal() { ); } + @Test + @DisplayName("GET /api/v1/rankings - 주간 MV 버전이 없으면 빈 content와 mvPublishVersion null") + void getRankings_weeklyMv_whenNoPublishedVersion_shouldReturnEmptyWithNullVersion() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?period=WEEKLY&periodKey=2026W53&page=1&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {}); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().dataSource()).isEqualTo("MV_WEEKLY"), + () -> assertThat(response.getBody().data().content()).isEmpty(), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0L), + () -> assertThat(response.getBody().data().mvPublishVersion()).isNull() + ); + } + @Test @DisplayName("GET /api/v1/rankings - period만 주면 400") void getRankings_whenPeriodWithoutKey_shouldReturn400() { From 1176a19b92819f9bd2892ac088d0b4ed0a6f47f5 Mon Sep 17 00:00:00 2001 From: Avocado Date: Fri, 17 Apr 2026 16:39:39 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20Round=2010=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EB=9E=AD=ED=82=B9=20=EA=B5=AC=ED=98=84=20=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=EB=A7=B5=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Round 10의 배치 랭킹 구현 원칙과 단계별 계획을 상세히 설명하는 로드맵 문서 추가. - 주간 및 월간 MV 조회를 포함한 랭킹 API의 정상 및 예외 시나리오를 정리한 문서 추가. - 배치 처리 및 Materialized View에 대한 설명과 운영 관점에서의 배치 전략을 포함하여, 향후 개발 및 유지보수에 필요한 참고 자료 제공. --- ...10-batch-ranking-implementation-roadmap.md | 442 ++++++++++++++++++ .docs/design/10-batch-ranking-mv-design.md | 208 +++++++++ .docs/design/10-ranking-user-scenarios.md | 139 ++++++ .docs/qna/10-qna.md | 164 +++++++ .docs/qna/10-quest.md | 120 +++++ .docs/qna/10-subject.md | 237 ++++++++++ 6 files changed, 1310 insertions(+) create mode 100644 .docs/Implementation/10-batch-ranking-implementation-roadmap.md create mode 100644 .docs/design/10-batch-ranking-mv-design.md create mode 100644 .docs/design/10-ranking-user-scenarios.md create mode 100644 .docs/qna/10-qna.md create mode 100644 .docs/qna/10-quest.md create mode 100644 .docs/qna/10-subject.md diff --git a/.docs/Implementation/10-batch-ranking-implementation-roadmap.md b/.docs/Implementation/10-batch-ranking-implementation-roadmap.md new file mode 100644 index 000000000..d02590b82 --- /dev/null +++ b/.docs/Implementation/10-batch-ranking-implementation-roadmap.md @@ -0,0 +1,442 @@ +# Round 10 - Batch 랭킹 구현 로드맵 (주간/월간 MV + API 확장) + +> 본 문서는 Round 10 구현 순서를 고정한다. +> 설계 근거는 [10-batch-ranking-mv-design.md](../design/10-batch-ranking-mv-design.md), 결정 로그는 [10-qna.md](../qna/10-qna.md)를 따른다. + +--- + +## 1. 구현 원칙 + +1. 대원칙은 항상 AGENTS.md, TDD.md를 먼저 읽고 해당 문서를 따라 구현한다. +2. TDD 순서: Unit -> Integration -> E2E +3. QnA 확정사항 우선 적용: + - Option A(staging -> switch) + - ISO week + KST + - TOP100 클램프(초과 시 빈 목록 + total) +4. 기존 `GET /api/v1/rankings?date=...` 하위호환을 깨지 않는다. + +--- + +## 2. 단계별 구현 계획 + + +| 단계 | 목표 | 주요 산출물 | +| --- | ------------ | -------------------------------------------------------------------- | +| 1 | 기간/키 계약 고정 | week/month key resolver, ISO/KST 경계 테스트 | +| 2 | MV 스키마 확정 | `mv_product_rank_weekly`, `mv_product_rank_monthly`, 인덱스/제약 | +| 3 | Batch Job 골격 | Job/Step 구성, 파라미터(`period`,`periodKey`) | +| 4 | Chunk 집계 구현 | Reader(`product_metrics`) / Processor(score, rank) / Writer(staging) | +| 5 | Option A 전환 | staging 검증 + active switch Tasklet | +| 6 | API 확장 | day/week/month 조회 분기 + 하위호환 + TOP100 클램프 | +| 7 | 관측/알림 | 실패 카운트, stale age, last success 시간 메트릭/알림 | +| 8 | 테스트 완성 | Unit 4, Integration 3, E2E 3 고정 세트 | + + +--- + +## 3. 상세 작업 순서 + +## 3.1 단계 - 기간 계산 유틸과 계약 테스트 + +- `Asia/Seoul` 기준 날짜 변환 유틸 작성 +- week key: ISO week-based-year 규칙 적용 +- month key: `yyyyMM` +- 테스트: + - `2026-01-01` 연초 경계 week key 검증 + - 월요일 시작 규칙 검증 + +## 3.2 단계 - MV DDL과 접근 레이어 + +- 주간/월간 MV 테이블 생성 +- `UNIQUE(period_key, product_id)` 보장 +- +- 조회 인덱스 `(period_key, rank)` 추가 +- repository 포트/구현 분리 + +## 3.3 단계 - Batch Job 파라미터화 + +- Job 파라미터: + - `period`: `WEEKLY` or `MONTHLY` + - `periodKey`: 예) `2026W15`, `202604` +- Step 구성: + - (필수) period lock Step: `period_type + period_key` 락 획득, 실패 시 `SKIP/중단` + - cleanup Tasklet (staging 초기화) + - aggregate Chunk Step + - publish Tasklet (switch) + +## 3.4 단계 - Chunk 집계 + +- Reader: `product_metrics` 페이지 단위 조회 +- Processor: period 집계 score 계산 + rank 후보 데이터 생성 +- Writer: staging에 upsert +- Top 100 제한은 Writer 직전/후 정렬 기준으로 고정 + +## 3.5 단계 - Option A publish + +- 검증 항목: + - row 수/순위 연속성/period 일치 +- 성공: + - active 버전 포인터 switch +- 실패: + - active 유지, 배치 실패 메트릭 증가 +- 동시 실행 충돌 방지: + - active switch는 **period lock 보유 실행만** 수행 가능 + +## 3.6 단계 - Ranking API 확장 + +- 하위호환: + - `date`만 오면 day 조회 유지 +- 확장: + - `period + periodKey`로 week/month 조회 +- (필수) 요청 단위 active version 고정: + - 요청 시작 시 `activeVersion`을 1회 결정하고, 해당 요청의 `total`/`rows`는 동일 version으로만 조회 +- TOP100 클램프: + - `total=100` 상한 + - 대고객 API: 범위 초과 페이지는 **빈 목록 + total 유지**, 서버 로그/메트릭에 "page 범위 초과" 기록 + - 내부/관리 API(필요 시 별도 경로): 동일 조건에서 **400 BAD_REQUEST** + +## 3.7 단계 - 모니터링 + +QnA 기준 우선순위: + +- P1: job failure count (1회 warning, 연속 3회 critical) +- P2: stale snapshot age (주기 2배/3배) +- P3: last successful time (24h/48h) + +메트릭 예시: + +- `batch.rank.job.failure.count` +- `batch.rank.snapshot.stale.seconds` +- `batch.rank.job.last.success.epoch` + +## 3.8 단계 - 테스트 고정 세트 + +- Unit(4) + 1. ISO 연초 week key + 2. month key + 3. TOP100 클램프 판정 + 4. period 파라미터 검증 +- Integration(3) + 1. 동일 period 재실행 멱등 + 2. 실패 시 active 유지(half-written 비노출) + 3. 성공 시 switch 원자성 +- Integration(추가 권장) + - period lock 경합 시 후행 실행이 switch를 수행하지 못하고 SKIP되는지 검증 +- E2E(3) + 1. 기존 `date` 하위호환 + 2. week/month 응답 계약 + 3. TOP100 초과 페이지 빈 목록 + total +- E2E(추가 권장) + - 요청 단위 active version 고정: 스위칭 타이밍과 무관하게 `total`/`rows`가 동일 version 기반으로 내려오는지 검증 + +--- + +## 4. 유즈케이스별 구현 흐름 + +```mermaid +flowchart TD + U0[User / Client] + + U0 --> U1[UC1 기존 일간 조회
GET rankings?date] + U1 --> I1[하위호환 분기
period 없으면 DAY] + I1 --> O1[일간 응답 유지] + + U0 --> U2[UC2 주간 TOP100 조회
period=WEEKLY] + U2 --> I2[ISO+KST week key 계산] + I2 --> B1[Batch 집계
product_metrics -> weekly MV] + B1 --> P1[Option A switch] + P1 --> O2[주간 랭킹 응답] + + U0 --> U3[UC3 월간 페이지 이동
period=MONTHLY] + U3 --> I3[monthly MV 조회] + I3 --> C1{page 범위 <= total(100)?} + C1 -->|Yes| O3[정상 rows + total] + C1 -->|No| O4[빈 목록 + total=100] + + U0 --> U4[UC4 배치 실패 시 조회] + U4 --> C2{검증 성공?} + C2 -->|No| O5[active 이전 스냅 유지] + C2 -->|Yes| O6[active 새 버전 노출] + + U0 --> U5[UC5 동일 period 재실행] + U5 --> I5[원장 재계산 + UNIQUE 제약] + I5 --> T1[결과 비교 test] + T1 --> O7[row/rank/score 동일] +``` + + + +### 4.1 기존 일간 랭킹 하위호환 + +- 유즈케이스 + - 기존 클라이언트가 `GET /api/v1/rankings?date=yyyyMMdd&page=1&size=20` 호출 +- 기대 + - 업그레이드 후에도 동일한 일간 랭킹 화면 유지 +- 구현 연결 + - 단계 6: `period` 파라미터가 없으면 day로 해석 + - 단계 8(E2E): 기존 `date` 요청이 그대로 동작하는지 회귀 테스트 + +```mermaid +sequenceDiagram + autonumber + participant C as Client(legacy) + participant API as Ranking API + participant R as Resolver + participant MV as Daily Source + + C->>API: GET /api/v1/rankings?date=yyyyMMdd&page&size + API->>R: resolvePeriod(request) + R-->>API: DAY (fallback) + API->>MV: read daily ranking + MV-->>API: rows,total + API-->>C: 200 + day ranking response +``` + + + +```mermaid +flowchart LR + A[Legacy Client
date,page,size] --> B[Ranking API] + B --> C{period 파라미터 존재?} + C -->|No| D[DAY fallback] + D --> E[Daily source 조회] + E --> F[200 + rows,total] +``` + + + +### 4.2 주간 TOP100 조회 + +- 유즈케이스 + - 사용자가 “주간 인기” 탭/섹션을 통해 주간 랭킹 조회 +- 기대 + - KST + ISO 주차 기준으로 정해진 주의 TOP100을 빠르게 조회 +- 구현 연결 + - 단계 1: `Asia/Seoul` + ISO week-based-year + Monday 규칙으로 week key 계산 + - 단계 2: `mv_product_rank_weekly` MV 테이블/인덱스 + - 단계 3~5: 배치가 `product_metrics`를 읽어 staging → 검증 → active 스위치 + - 단계 6: `period=WEEKLY&periodKey=...`로 주간 MV에서 조회 + +```mermaid +sequenceDiagram + autonumber + participant B as Batch Job + participant PM as product_metrics + participant S as weekly_staging + participant A as active pointer + participant API as Ranking API + participant C as Client + + B->>PM: read WEEKLY window + PM-->>B: metrics rows + B->>S: write top100 candidates + B->>S: validate rank/period + B->>A: switch weekly active version + C->>API: GET /api/v1/rankings?period=WEEKLY&periodKey=... + API->>A: resolve weekly active version + API->>S: read active weekly snapshot + API-->>C: weekly rows,total +``` + + + +```mermaid +flowchart LR + A[Client
period=WEEKLY] --> B[Ranking API] + B --> C[week key resolve
ISO+KST] + C --> D[active weekly version 조회] + D --> E[weekly MV 조회] + E --> F[rows,total 응답] +``` + + + +### 4.3 월간 TOP100 페이지 이동 (경계 포함) + +- 유즈케이스 + - 사용자가 월간 인기에서 페이지 이동 (`page=2,size=20`) 또는 과도한 page 요청 +- 기대 + - 1~100위 범위 내에서는 정상 페이지 이동 + - TOP100 범위를 넘어서는 요청은 정책대로 처리 +- 구현 연결 + - 단계 2: `mv_product_rank_monthly`에 TOP100 저장 + - 단계 6: `total=100` 상한, 범위 초과 시 대고객 API는 빈 목록 + total 유지 + - 단계 8(E2E): 마지막 정상 페이지와 초과 페이지 동작을 테스트로 고정 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant API as Ranking API + participant M as monthly MV + + C->>API: GET ...?period=MONTHLY&periodKey=...&page=2&size=20 + API->>M: read total + page slice + M-->>API: total<=100, rows + API-->>C: 200 + rows,total + + C->>API: GET ...?period=MONTHLY&periodKey=...&page=6&size=20 + API->>M: evaluate page range against total=100 + API-->>C: 200 + empty rows,total=100 +``` + + + +```mermaid +flowchart LR + A[Client
period=MONTHLY,page,size] --> B[Ranking API] + B --> C[monthly total 계산
cap=100] + C --> D{요청 범위 <= total?} + D -->|Yes| E[page slice 반환] + D -->|No| F[빈 목록 반환] + E --> G[200 + rows,total] + F --> G +``` + + + +### 4.4 배치 실패 시 깨지지 않는 랭킹(Option A) + +- 유즈케이스 + - 배치가 실행 중/실패했더라도 사용자는 “반쯤 들어간 랭킹”을 보지 않음 +- 기대 + - 새 버전이 완전히 준비되기 전까지는 이전 정상 결과만 노출 +- 구현 연결 + - 단계 5: Option A - staging에서 완성 후 검증 성공 시에만 active 스위치, 실패 시 active 유지 + - 단계 7: 실패 카운트/노후화 메트릭으로 운영자에게 알림 + - 단계 8(Integration): 실패 시에도 active 버전이 이전 상태로 유지되는지 검증 + +```mermaid +sequenceDiagram + autonumber + participant B as Batch Job + participant S as staging MV + participant A as active pointer + participant API as Ranking API + participant C as Client + participant MON as Monitor + + B->>S: write new snapshot + B->>S: validate + alt validation fail + B-->>A: keep previous active version + B->>MON: emit failure + stale risk + else success + B->>A: switch active version + end + C->>API: GET rankings + API->>A: resolve active version + API-->>C: serve stable snapshot +``` + + + +```mermaid +flowchart LR + A[Batch write to staging] --> B[validate] + B -->|Success| C[switch active] + B -->|Fail| D[keep previous active] + C --> E[API reads new active] + D --> F[API reads previous active] + E --> G[stable response] + F --> G +``` + + + +### 4.5 동일 period 재실행 멱등성 + +- 유즈케이스 + - 운영자가 동일 `period_type + period_key`로 배치를 재실행(복구/재집계) +- 기대 + - 재실행 전후 MV 내용이 동일 (`row 수`, `rank`, `score`) +- 구현 연결 + - 단계 2: `UNIQUE(period_key, product_id)` 제약으로 중복 행 방지 + - 단계 4~5: `product_metrics` 원장 기준 재계산 + staging → active 스위치 + - 단계 8(Integration): 동일 period 2회 실행 결과가 완전히 동일한지 검증 + +```mermaid +sequenceDiagram + autonumber + participant O as Operator + participant B as Batch Job + participant PM as product_metrics + participant S as staging MV + participant A as active pointer + participant T as Integration Test + + O->>B: run(period=WEEKLY,key=2026W15) #1 + B->>PM: recalculate from source + B->>S: upsert with UNIQUE(period_key,product_id) + B->>A: switch active (v1) + + O->>B: run(period=WEEKLY,key=2026W15) #2 + B->>PM: recalculate same source window + B->>S: upsert same deterministic result + B->>A: switch active (v2) + + T->>A: compare v1 vs v2 + T-->>O: row/rank/score identical +``` + + + +```mermaid +flowchart LR + A[Run #1 same period] --> B[recalculate from product_metrics] + B --> C[write staging + UNIQUE] + C --> D[switch active v1] + + E[Run #2 same period] --> F[recalculate same window] + F --> G[write staging same rule] + G --> H[switch active v2] + + D --> I[Integration compare] + H --> I + I --> J{row/rank/score 동일?} + J -->|Yes| K[idempotent] + J -->|No| L[regression] +``` + + + +--- + +## 5. 시퀀스(Option A) + +```mermaid +sequenceDiagram + autonumber + participant B as Batch Job + participant PM as product_metrics + participant S as staging MV + participant A as active pointer + participant API as Ranking API + + B->>PM: read by chunk + PM-->>B: metrics page + B->>S: upsert aggregated rows + B->>S: validate(period/top100/rank) + alt success + B->>A: switch active version + API->>A: resolve active version + API->>S: read active snapshot + else failure + B-->>A: keep previous version + API->>A: previous version read + end +``` + + + +--- + +## 6. Done 기준 + +- 배치 파라미터 기반 주/월 집계 성공 +- MV에 TOP100 저장 및 재실행 멱등 보장 +- API가 day/week/month 제공 + 기존 `date` 하위호환 유지 +- half-written 노출 없음 +- QnA에서 정한 모니터링 임계치 반영 +- 테스트 세트(Unit 4 / Integration 3 / E2E 3) 통과 + diff --git a/.docs/design/10-batch-ranking-mv-design.md b/.docs/design/10-batch-ranking-mv-design.md new file mode 100644 index 000000000..4c9aefe0d --- /dev/null +++ b/.docs/design/10-batch-ranking-mv-design.md @@ -0,0 +1,208 @@ +# Round 10 - Spring Batch 기반 주간/월간 랭킹 설계 + +> **TL;DR**: Round 10의 핵심은 `product_metrics`(일간 원장)를 기준으로 주간/월간 TOP 100을 **배치로 사전 집계(Materialized View)** 하고, API에서 일/주/월을 같은 계약으로 조회하는 것이다. +> QnA에서 확정한 운영 원칙은 **Option A(staging 완성 후 switch)**, **ISO 주차 + KST**, **TOP 100 클램프(page/size 초과 시 빈 목록 + total 유지)** 이다. + +**관련 문서** + +- 요구/학습 원문: [../qna/10-subject.md](../qna/10-subject.md), [../qna/10-quest.md](../qna/10-quest.md) +- 설계 QnA(결정 기록): [../qna/10-qna.md](../qna/10-qna.md) +- 구현 순서: [../Implementation/10-batch-ranking-implementation-roadmap.md](../Implementation/10-batch-ranking-implementation-roadmap.md) +- 선행 레퍼런스: [09-ranking-redis-zset-design.md](./09-ranking-redis-zset-design.md) + +--- + +## 1. Scope와 결정사항 + + +| 구분 | 이번 라운드 고정 | +| ------ | ---------------------------------------------------------------- | +| 입력 원장 | `product_metrics` | +| 집계 출력 | `mv_product_rank_weekly`, `mv_product_rank_monthly` | +| 배치 모델 | Chunk 중심 + 필요 시 Tasklet 혼합 | +| 공개 방식 | Option A: staging 완성 후 active switch | +| 시간 규칙 | `Asia/Seoul`, 주 시작 월요일, ISO-8601 week-based-year | +| API 확장 | 기존 `/api/v1/rankings`를 day/week/month로 확장, 기존 `date` 요청 하위 호환 유지 | +| TOP 제한 | MV는 TOP 100, API는 total=100 기준 클램프(범위 초과 시 빈 목록) | + + +--- + +## 2. 왜 배치 + MV인가 + +- `10-subject`가 말한 대로 주/월 랭킹은 즉시성보다 정확성과 비용 효율이 중요하다. +- 매 요청마다 주/월 집계를 계산하면 DB 부하가 커져 API 응답 안정성을 해친다. +- 따라서 조회 전용 MV에 사전 집계하고 API는 읽기 전용 경로로 단순화한다. + +--- + +## 3. 데이터 모델 설계 + +## 3.1 MV 테이블 + +- `mv_product_rank_weekly` +- `mv_product_rank_monthly` + +권장 컬럼: + +- `period_key` (예: `2026W15`, `202604`) +- `product_id` +- `rank` (1~100) +- `score` +- `updated_at` +- `version` (Option A switch용) + +권장 제약: + +- `UNIQUE(period_key, product_id)` +- 조회 인덱스: `(period_key, rank)` + +근거: + +- QnA Q3에서 같은 period 재실행 멱등성과 중복 방지 요구를 확정했다. + +## 3.2 기간 키 규칙 + +- timezone: `Asia/Seoul` +- week: ISO-8601 week-based-year + Monday +- month: `yyyyMM` +- 연초 경계(`2026-01-01` 등): ISO 계산 결과를 그대로 week key로 사용 + +--- + +## 4. 배치 공개 전략 (Option A) + +QnA Q5 결정: + +- staging 테이블/버전에 먼저 완성 +- 검증 성공 시에만 active 포인터/버전 switch +- 실패 시 active는 이전 스냅 유지 + +효과: + +- 반쯤 적재된 데이터 비노출 +- 빈 결과 방지(이전 스냅 허용) +- 같은 period 재실행 멱등성 강화 + +--- + +## 4.1 멱등성 보장 규칙 + +멱등성 정의: + +- 같은 `period_type + period_key`로 배치를 여러 번 실행해도 최종 공개 결과가 동일해야 한다. + +적용 규칙: + +1. 입력 고정 + - 집계 입력은 `product_metrics` 원장만 사용한다. + - 같은 period 실행 시 동일한 기간 규칙(KST/ISO)으로 동일 입력 집합을 구성한다. +2. 계산 고정 + - Top 100 선정/정렬/rank 부여 규칙을 실행마다 동일하게 적용한다. + - 배치 내 증분 누적(INCR) 대신 "원장 재계산 결과"를 기준으로 반영한다. +3. 쓰기 고정 (Option A) + - staging에 해당 period 결과를 완성한 후 검증 성공 시에만 active switch한다. + - 실패 실행은 active에 반영하지 않으며 이전 스냅을 유지한다. +4. 제약 고정 + - `UNIQUE(period_key, product_id)`를 유지하여 같은 period 중복 행 생성을 차단한다. + +검증 규칙: + +- 동일 period 재실행 통합 테스트에서 `row 수`, `rank`, `score`가 실행 간 동일해야 한다. +- 중간 실패 후 재실행 테스트에서 실패 실행 결과가 active에 노출되지 않아야 한다. + +--- + +## 5. API 계약 + +대상: `GET /api/v1/rankings` + +계약 방향: + +- 기존 `date=yyyyMMdd&page&size`는 그대로 지원(일간 해석) +- 기간 확장 파라미터 추가(예: `period` + `periodKey`) +- 응답은 공통 포맷 유지, `total` 포함 + +TOP 100 클램프: + +- `total = min(집계건수, 100)` +- 대고객 랭킹 API: 요청 범위가 `total` 초과면 응답은 **빈 목록 + total 유지**, 서버 로그/메트릭에서는 "page 범위 초과"로 기록 +- 내부/관리용 API(필요 시 별도 경로): 동일 조건에서 **400 BAD_REQUEST**로 명시적인 계약 위반 처리 + +--- + +## 6. Spring Batch 모델 선택 + +- 본 집계 Step: Chunk (`Reader -> Processor -> Writer`) +- 전/후처리(정리, 검증 마킹, 포인터 갱신): Tasklet 가능 + +혼합 권장 시나리오: + +1. Tasklet: 대상 period staging 정리 +2. Chunk: 집계 결과 적재 +3. Tasklet: 검증 후 active switch + +근거: + +- QnA Q2에서 Chunk/Tasklet 혼합 사용 가능성을 확인했고, +- `10-quest`가 대량 `product_metrics` 처리와 MV 적재를 요구한다. + +--- + +## 7. 모니터링/알림 우선순위 + +QnA Q6 확정: + +- P1: job failure count + - warning: 1회 실패 + - critical: 연속 3회 실패 +- P2: stale snapshot age + - warning: 주기 2배 초과 + - critical: 주기 3배 초과 +- P3: last successful time + - warning: 24시간 초과 + - critical: 48시간 초과 + +--- + +## 8. 테스트 전략(최소 세트) + +QnA Q8 확정: + +- Unit 4 +- Integration 3 +- E2E 3 + +필수 커버: + +1. ISO 연초 경계 week key +2. 동일 period 재실행 멱등 +3. 실패 시 half-written 비노출 +4. TOP100 초과 page/size 클램프 + +--- + +## 9. 리스크와 완화 + + +| 리스크 | 완화 | +| ---------------------------------- | --------------------------------------------------------------------------------------- | +| 주차 계산 불일치 | ISO/KST 규칙을 도메인 유틸 + 단위테스트로 고정 | +| Option A 스위칭 레이스(조회가 구/신 버전 혼합 조회) | active 포인터 스위칭을 원자적으로 처리하고, 조회는 active 버전만 읽도록 강제 | +| 동일 period 동시 실행 충돌 | period 단위 배치 락(분산락/DB락) 적용, 중복 실행 시 후행 잡 중단 | +| 재실행 시 중복/왜곡 | `멱등성 보장 규칙` 적용(원장 재계산, `UNIQUE(period_key, product_id)`, Option A switch, 재실행 통합테스트 고정) | +| TOP100 경계 오프바이원(100위 누락/중복) | `(page,size,total=100)` 경계 단위테스트 + E2E 클램프 테스트 고정 | +| 집계 실패 노출 | active 이전 스냅 유지 | +| API 계약 파손 | 기존 `date` 하위호환 유지 + 점진 전환(새 파라미터 optional/feature flag) + 하위호환 E2E 고정 | +| 모니터링 왜곡(실패/노후 상태 탐지 누락) | P1/P2/P3 메트릭 분리 수집 및 임계치 알림 고정 | +| 점수 규칙 변경 이력 부재로 설명 불가 | MV 버전/스코어 규칙 버전 필드 관리, 배포 노트에 변경 이력 기록 | + + +### 9.1 동일 period 동시 실행 충돌 완화 규칙 + +- 락 단위: `period_type + period_key` (예: `WEEKLY:2026W15`) +- 실행 시작 시 period 락을 먼저 획득하고, 락 획득 실패 실행은 즉시 `SKIP/중단` 처리 +- `active` 포인터 스위칭은 락 보유 실행만 수행 가능 +- 실행 로그/메트릭에 `run_id`, `period_key`, `lock_owner`를 남겨 사후 추적 가능하게 유지 +- 알림은 "동일 period 중복 실행 감지" 이벤트를 warning 이상으로 분리 발행 + diff --git a/.docs/design/10-ranking-user-scenarios.md b/.docs/design/10-ranking-user-scenarios.md new file mode 100644 index 000000000..1e50b8f52 --- /dev/null +++ b/.docs/design/10-ranking-user-scenarios.md @@ -0,0 +1,139 @@ +# Round 10 랭킹 — 동작·시나리오 (day/week/month + MV) + +> **이 문서가 하는 일**: Round 10에서 추가된 주간/월간 MV 조회를 포함해, 랭킹 API가 정상/예외에서 어떤 결과를 내는지 정리한다. +> **근거 문서**: [10-batch-ranking-mv-design.md](./10-batch-ranking-mv-design.md), [10-batch-ranking-implementation-roadmap.md](../Implementation/10-batch-ranking-implementation-roadmap.md), [10-qna.md](../qna/10-qna.md), [09-ranking-user-scenarios.md](./09-ranking-user-scenarios.md), [09-ranking-risk-discovery.md](./09-ranking-risk-discovery.md). + +--- + +## 0. Round 10에서 바뀐 핵심 + + +| 항목 | Round 9 | Round 10 | +| -------- | ----------------------- | ------------------------------- | +| 조회 기간 | day(일간) 중심 | day + week + month | +| 데이터 원천 | Redis ZSET 중심 | day=Redis, week/month=MV | +| 주/월 안정성 | 해당 없음 | 요청 시작 시 `MAX(version)` 1회 고정 조회 | +| 페이지 정책 | 범위 초과 시 빈 목록 + total 유지 | 동일(주/월은 TOP100 클램프 포함) | +| 배치 실패 노출 | 해당 없음 | Option A로 half-written 비노출 | + + +근거: 로드맵 3.5/3.6/3.7, Done 기준의 `half-written 비노출`, `TOP100`, `모니터링`. + +--- + +## 1. 정상 시나리오 (Happy Path) + + +| ID | 트리거 | 호출/처리 | 관찰 가능한 결과 | +| --- | ---------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------- | +| H1 | `GET /api/v1/rankings?date=yyyyMMdd&page&size` | 일간 Redis 키 조회 | HTTP 200, `dataSource=REDIS`(또는 스냅샷/fallback 정책 경로), 기존 하위호환 유지 | +| H2 | `GET /api/v1/rankings?period=WEEKLY&periodKey=2026W15&page&size` | 주간 MV에서 `MAX(version)` 결정 후 해당 버전만 조회 | HTTP 200, `dataSource=MV_WEEKLY`, `mvPublishVersion` 포함 | +| H3 | `GET /api/v1/rankings?period=MONTHLY&periodKey=202604&page&size` | 월간 MV 동일 규칙 | HTTP 200, `dataSource=MV_MONTHLY`, `mvPublishVersion` 포함 | +| H4 | 주/월 조회 page 범위 내 | `total=min(실데이터,100)` 기준 슬라이스 | content 정상, `totalElements<=100` | +| H5 | 주/월 조회 page 범위 초과 | total 대비 오프셋 초과 | HTTP 200, `content=[]`, `totalElements` 유지 | +| H6 | 배치 성공 (`rankingProductMvJob`) | staging 검증 성공 후 publish | 다음 조회부터 새 snapshot 반영 | +| H7 | 배치 실패 | Option A에서 switch 미수행 | 이전 정상 snapshot 계속 노출(반쯤 쓴 데이터 비노출) | + + +--- + +## 2. 예외·경계 시나리오 + +### 2.1 입력/계약 검증 + + +| ID | 조건 | 결과 | +| --- | ----------------------------------------------------- | --------------- | +| E1 | `period`만 있고 `periodKey` 없음 | 400 BAD_REQUEST | +| E2 | `periodKey`만 있고 `period` 없음 | 400 BAD_REQUEST | +| E3 | `date`와 `period/periodKey` 동시 지정 | 400 BAD_REQUEST | +| E4 | WEEKLY key 포맷 오류 (`20260406`) 또는 주차 범위 이탈 (`2026W00`) | 400 BAD_REQUEST | +| E5 | MONTHLY key 포맷 오류 (`2026-04`) 또는 월 범위 이탈 (`202613`) | 400 BAD_REQUEST | + + +### 2.2 MV 조회 안정성 + + +| ID | 조건 | 결과 | +| --- | ---------------------------------------- | ----------------------------------- | +| E6 | periodKey에 MV row 없음 (`MAX(version)` 없음) | 200 + 빈 목록, `mvPublishVersion=null` | +| E7 | 동일 periodKey에 버전 혼재(v1, v2) | 요청 시작 시 선택된 `MAX(version)`만 응답 | +| E8 | 조회 중 배치가 새 버전 publish | 이미 시작된 요청은 기존 선택 버전 기준으로 일관 응답 | + + +### 2.3 배치/운영 경계 + + +| ID | 조건 | 결과 | +| --- | ---------------------------- | ---------------------------------------------------------------------------- | +| E9 | 동일 period 락 선점 상태에서 후행 실행 | period lock step에서 실패(중복 실행 방지) | +| E10 | staging 검증 실패(rank 불연속/중복 등) | publish 실패, 기존 MV 유지 | +| E11 | 배치 실패 반복 | `batch.rank.job.failure.count` 증가(알람 연계 대상) | +| E12 | 성공 배치 장시간 없음 | `batch.rank.snapshot.stale.seconds`, `batch.rank.job.last.success.epoch`로 탐지 | + + +--- + +## 3. 사용자 관점 시나리오 + + +| ID | 사용자 행동 | 기대 화면/응답 | +| --- | ------------------------- | ---------------------------- | +| U1 | 기존 앱이 `date`만 보내 일간 랭킹 요청 | 이전과 동일하게 동작(회귀 없음) | +| U2 | 주간 탭 진입 후 페이지 이동 | TOP100 내 정상 이동, 초과 페이지는 빈 목록 | +| U3 | 월간 탭에서 마지막 페이지 이후 요청 | 오류 대신 빈 목록 + total 유지 | +| U4 | 배치 실패 직후 랭킹 재조회 | 이전 정상 랭킹 유지(깨진 순위 노출 없음) | +| U5 | 배치 재실행(동일 period) 후 재조회 | 결과 동일(멱등), 사용자 체감 변동 없음 | + + +--- + +## 4. 리스크 연결 (Round 9 → Round 10) + +Round 9 리스크 중 Round 10에서도 중요한 항목을 재정리한다. + + +| Risk | Round 10 상태 | 확인 포인트 | +| ---------------------- | --------------------------- | --------------------------------- | +| R3 (Redis/DB 불일치) | day 경로에서는 여전히 유효 | day 응답의 total/rows 불일치 가능성 문서화 유지 | +| R7 (오프셋 시프트) | day 라이브 조회에 여전히 유효 | 주/월 MV는 요청 버전 고정으로 완화 | +| R1 (장애/빈 목록 구분) | 주/월은 MV 조회로 상대적 완화, day는 유지 | dataSource/메트릭으로 운영 구분 | +| 신규 R10-1 (publish 원자성) | Option A로 대응 | 실패 시 active 유지 테스트 필수 | +| 신규 R10-2 (버전 혼재 조회) | 요청당 `MAX(version)` 고정으로 대응 | 혼합 버전 E2E 필수 | + + +--- + +## 5. 테스트 매핑 체크리스트 (로드맵 3.8 기준) + + +| 구분 | 시나리오 | +| ----------- | --------------------------------------------------------- | +| Unit | ISO 연초 week key, month key, TOP100 클램프, period 파라미터 검증 | +| Integration | 동일 period 재실행 멱등, 실패 시 active 유지, 성공 시 publish/switch | +| E2E | `date` 하위호환, week/month 응답 계약, TOP100 초과 페이지 빈 목록 + total | +| 추가 권장 | period lock 경합, 요청 단위 active version 고정 | + + +--- + +## 6. 운영 확인 항목 + + +| 항목 | 기준 (QnA) | +| --- | ----------------------------------------------------------- | +| P1 | `batch.rank.job.failure.count` — 1회 warning, 연속 3회 critical | +| P2 | `batch.rank.snapshot.stale.seconds` — 주기 2배/3배 임계 | +| P3 | `batch.rank.job.last.success.epoch` — 24h/48h 임계 | + + +--- + +## 7. 관련 구현/문서 링크 + +- 설계: [10-batch-ranking-mv-design.md](./10-batch-ranking-mv-design.md) +- 구현 순서: [10-batch-ranking-implementation-roadmap.md](../Implementation/10-batch-ranking-implementation-roadmap.md) +- 결정 로그: [10-qna.md](../qna/10-qna.md) +- 선행 시나리오: [09-ranking-user-scenarios.md](./09-ranking-user-scenarios.md) +- 선행 리스크: [09-ranking-risk-discovery.md](./09-ranking-risk-discovery.md) + diff --git a/.docs/qna/10-qna.md b/.docs/qna/10-qna.md new file mode 100644 index 000000000..bfadbac73 --- /dev/null +++ b/.docs/qna/10-qna.md @@ -0,0 +1,164 @@ +# Week 10 - Spring Batch 기반 랭킹 확장 설계 QnA + +--- + +## Q1. 주간/월간 랭킹을 실시간이 아닌 배치로 분리하려는 이유는 무엇인가요? + +**[질문]** +`10-subject`에서는 실시간 처리 vs 배치 처리의 트레이드오프를 강조하고, `10-quest`에서는 주간/월간 랭킹을 배치로 구현하라고 요구합니다. +당신은 이 요구를 어떤 기준으로 해석했고, 왜 배치가 더 적합하다고 판단했나요? + +**[답변]** + +> 실시간으로 처리한다면 가장 좋겠지만 대규모 시스템의 경우에는 실시간 처리가 어렵습니다. 랭킹 배치 처리가 적합한 것 같습니다. 주간/월간 랭킹은 아직 주간, 월간의 기준을 어떤 단위로 끊어야 하는지 기준이 명확하게 잡히지 않았어 + +--- + +## Q2. Chunk vs Tasklet for weekly / monthly ranking steps + +**[Question]** +`10-subject` introduces Chunk-Oriented Processing and Tasklet. `10-quest` asks to read `product_metrics` in bulk and aggregate. +For weekly TOP 100 and monthly TOP 100, will you implement each Step as Chunk, Tasklet, or a mix? What rule do you use to choose? If week/month boundaries are still unclear, what constraint or small experiment would you use to decide? + +**[Answer]** +> 일단 Chunk, Tasklet을 잘 알지 못하는 상황이니 둘다에 대한 개념 설명을 듣고 싶어. 그리고 기존에 있는 product_metrics 테이블을 통해 집계를 해야겠어. 일단 먼저 답변에 대한 설명을 해줘 그 이후 추가 질문을 할게 + +--- + +**[Mentor note]** (Chunk / Tasklet - concept only, not a single correct design) + +- **Chunk-oriented step**: `ItemReader` reads rows (often with paging). `ItemProcessor` maps each row. Items buffer until chunk size N, then `ItemWriter` persists that batch in one transaction. Loop until the reader is done. Fits **large** scans of `product_metrics` with bounded memory. +- **Tasklet**: runs **one** piece of work per step (any code: one SQL, file copy, etc.). No Reader/Processor loop. Fits **one-shot** steps (e.g. truncate a staging table, run one aggregate statement). +- **Choosing**: many rows, repeated read-process-write → usually Chunk. a single procedural action or one SQL → often Tasklet. **Mix** is common: e.g. Tasklet to clear target period in MV, then Chunk to refill. + +--- + +## Q3. MV tables and idempotent batch runs + +**[Question]** +`10-quest` names `mv_product_rank_weekly` and `mv_product_rank_monthly` (TOP 100). If the job fails halfway and you rerun with the **same** period parameters, how do you avoid duplicate rows or wrong ranks? What is your **primary key / period** design? Do you prefer truncate-and-load, delete-by-period-then-insert, or upsert? + +**[Answer]** +> 1. 멱등 필수 -> 기간 단위로 덮어쓰기 +> 2. 동시 실행 가능 -> 잠금 필수 +> 3, 4. (앞서 제시한 기준) 그대로 진행 +> 5. 부분 실패 시 반쯤 들어간 데이터는 노출되면 안 될 것 같음 +> 6. (적재 방식 축) 진행 +> 7. 빈 결과는 안 됨, 이전 스냅은 허용 + +--- + +## Q4. Ranking API: day / week / month contract + +**[Question]** +`10-quest` asks to extend `GET /api/v1/rankings` so callers can request daily, weekly, and monthly rankings (today you have `date=yyyyMMdd`, `size`, `page`). +How will you represent **which period** the client is asking for (day vs week vs month) in query parameters? How do you identify a **week** and a **month** (e.g. `week=2026W15`, `month=202604`, or a single `period` enum + `periodKey`)? +How do you keep **backward compatibility** for existing clients that only send `date`? +What happens when `page` * `size` goes beyond **TOP 100** stored in MV? + +**[Answer]** +> 1. 주, 월 식별자는 월요일을 시작으로 잡아 타임존 날짜에서 잘라서 사용 +> 2. 파라미터를 바꾸지 않았다면 그날의 일간 랭킹으로 해석되어 특정 시각의 랭킹을 보여줌 +> 3. page/size는 total(최대 100) 기반으로 처리하고, 요청 범위가 total을 넘으면 빈 목록을 반환하는 방식(클램프)을 사용 + +--- + +## Q5. Batch failure handling and visibility rule + +**[Question]** +You chose: "half-written data must not be exposed" and "empty result is not allowed, previous snapshot is allowed." +What is your concrete publish rule? +- Option A: write to staging table, then switch pointer/version only after success +- Option B: write to target table in one transaction for each period +- Option C: another approach + +Also, what monitoring signal should trigger alert first: job failure count, last successful time, or stale snapshot age? + +**[Answer]** +> Option A를 선택. +> staging 테이블에 주/월 랭킹을 완성한 뒤 검증 성공 시에만 포인터/버전을 스위칭. +> 부분 적재 데이터는 노출하지 않고, 실패 시 이전 스냅을 유지. + +--- + +## Q6. Monitoring priority for Option A + +**[Question]** +For the Option A flow you chose, which alert do you want as **P1** and why? +- job failure count +- last successful time +- stale snapshot age + +Please pick one as P1, and set the others as P2/P3 with simple threshold ideas. + +**[Answer]** +> P1: job failure count +> - threshold: 1회 실패 시 warning, 연속 3회 실패 시 critical +> P2: stale snapshot age +> - threshold: 기대 배치 주기 대비 2배 초과 시 warning, 3배 초과 시 critical +> P3: last successful time +> - threshold: 마지막 성공이 24시간 초과 시 warning, 48시간 초과 시 critical + +--- + +## Q7. Week/Month boundary contract and timezone + +**[Question]** +You said weekly/monthly keys will be derived from date format splitting. +Please make the contract explicit for implementation and tests: +1) Which timezone is canonical? (e.g. Asia/Seoul) +2) What is the first day of week? (Monday/Sunday) +3) Which week rule? (ISO-8601 week-based year or custom) +4) If `date=YYYYMMDD` is at year boundary (e.g. 2026-01-01), which week key should it map to? + +Please answer as concrete rules, not intentions. + +**[Answer]** +> 1) timezone: 기존 서비스 타임존 유지 (Asia/Seoul) +> 2) first day of week: Monday +> 3) week rule: ISO-8601 week-based year +> 4) year boundary: 연초 경계 날짜도 ISO week-year 규칙으로 계산 (예: 2026-01-01의 week key는 ISO 계산 결과를 따른다) + +--- + +## Q8. Minimal test set for your contracts + +**[Question]** +Based on your decisions so far (Option A, TOP 100 clamp, ISO week rule, KST), propose a **minimal but sufficient** test set. +Please list test cases by level: +- unit test (domain/helper) +- integration test (batch + DB) +- API/E2E test + +At least include: +1) year-boundary week key mapping (ISO) +2) rerun idempotency for same period +3) no half-written exposure on failure +4) page/size beyond TOP 100 returns clamped empty list with total + +How many tests in each level, and why that is enough? + +**[Answer]** +> 단위(Unit) 4개, 통합(Integration) 3개, API/E2E 3개로 구성. +> +> [Unit - 4] +> 1) week key 계산: KST + ISO 기준으로 연초 경계(`2026-01-01`)가 기대 week key로 매핑되는지 검증 +> 2) month key 계산: `yyyyMM` 파생 규칙 검증 +> 3) TOP100 클램프 계산: `(page,size,total=100)`에서 범위 초과 시 empty 판정되는지 검증 +> 4) period 파라미터 파싱/검증: day/week/month 입력 계약 검증 +> +> [Integration - 3] +> 1) 같은 period로 배치 2회 실행 시 결과 동일(멱등): row 수/랭크/점수 동일 검증 +> 2) Option A 실패 시 노출 보호: staging 적재 중 실패를 강제하고 active 포인터가 이전 스냅을 유지하는지 검증 +> 3) 성공 시 스위칭: staging 완료 후 active 포인터가 새 버전으로 원자적으로 전환되는지 검증 +> +> [API/E2E - 3] +> 1) 기존 `date`만 보낸 요청 하위호환: 일간 랭킹 정상 응답 검증 +> 2) 주간/월간 요청 응답: period별로 total/rows/rank 형식이 계약대로 내려오는지 검증 +> 3) page*size가 TOP100 초과: 빈 목록 + total(100) 반환 검증 +> +> 왜 이 구성이 충분한가: +> - 규칙성 계산(KST/ISO/클램프)은 Unit에서 빠르게 고정 +> - 실패/스위칭/멱등 같은 핵심 안정성은 Integration에서 보장 +> - 사용자 계약(하위호환/응답 포맷/클램프 동작)은 E2E에서 최종 확인 +> - 즉, 계산/저장/노출 3축을 각각 한 번씩 닫아 최소 개수로 핵심 리스크를 커버 diff --git a/.docs/qna/10-quest.md b/.docs/qna/10-quest.md new file mode 100644 index 000000000..c0fdccd45 --- /dev/null +++ b/.docs/qna/10-quest.md @@ -0,0 +1,120 @@ +# 📝 Round 10 Quests + +--- + +## 💻 Implementation Quest + +> 이번에는 Spring Batch 를 활용해 주간, 월간 랭킹을 제공해 볼 거예요. +이전에 적재했던 `product_metrics` 와 같은 일간 집계정보를 기반으로 **주간, 월간 랭킹 시스템을 구축**해봅니다. +> + +

+ +### 📋 과제 정보 + +이번 주는 대규모 데이터 집계 및 조회 전용 구조에 대한 설계를 진행해 봅니다. + +### (1) Spring Batch Job 구현 + +- 하루치 메트릭 테이블을 읽어 데이터를 집계하고 처리해봅니다. + - 대상 테이블 : `product_metrics` + - Chunk-Oriented 방식을 통해 대량의 데이터를 읽고 처리할 수 있도록 구성해 보세요. + +### (2) Materialized View 설계 + +- 집계 결과를 조회 전용 테이블 (MV) 로 저장합니다. + - `mv_product_rank_weekly` : 주간 TOP 100 랭킹 + - `mv_product_rank_monthly` : 월간 TOP 100 랭킹 + +### (3) Ranking API 확장 + +- 기존 Ranking 을 제공하는 GET `/api/v1/rankings?date=yyyyMMdd&size=20&page=1` 에서 기간 정보를 전달받아 API 로 일간, 주간, 월간 랭킹을 제공할 수 있도록 개선합니다. + +--- + +## ✅ Checklist + +### 🧱 Spring Batch + +- [ ] Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다. +- [ ] Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다. +- [ ] 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다. + +### 🧩 Ranking API + +- [ ] API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다. + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 📚 Technical Writing Guide + +### ✅ 작성 기준 + +| 항목 | 설명 | +| --- | --- | +| **형식** | 블로그 | +| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 | +| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 | +| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” | + +--- + +### ✨ 좋은 톤은 이런 느낌이에요 + +> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자 +> + +| 특징 | 예시 | +| --- | --- | +| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 | +| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 | +| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… | + +### ❌ 피해야 할 스타일 + +| 예시 | 이유 | +| --- | --- | +| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 | +| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 | +| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 | + +### 🎯 Retrospective + +- 단순히 “무엇을 했다”가 아니라, **10주 동안 어떻게 성장했는지**를 돌아본다. +- “기능 구현” 중심이 아니라, **사고방식/문제 해결/설계 선택 과정** 중심으로 기록한다. +- 이 글은 **개인 포트폴리오**이자, 앞으로 학습 방향을 스스로 점검하는 기준점이 된다. + +### 담으면 좋은 내용 + +1. **전체 여정 요약** + - 1~10주차 동안 다뤘던 주요 테마 및 문제점들을 간단히 돌아보기 + - 단순 나열이 아니라, **흐름이 어떻게 연결되었는지** 를 강조 +2. **가장 큰 전환점** + - **내 기존의 사고방식이 바뀌었다** 싶은 순간 + - *예: 4주차 트랜잭션/락을 통해 단순 @Transactional 이상의 고민을 알게 된 점, 7주차 이벤트 분리를 통해 ‘확장성’에 눈을 뜬 경험* +3. **나의 Trade-off 판단** + - 실습 중 내가 내린 중요한 선택 1~2개 + - 왜 그 선택을 했고, 대안은 뭐였는지, 지금 다시 한다면 어떻게 할 건지 +4. **실전과의 연결** + - “이건 실제 회사/서비스에서 써먹을 수 있겠다” 싶은 포인트 + - *예: 캐시 무효화 전략, Kafka 기반 집계, Resilience4j 설정 등* \ No newline at end of file diff --git a/.docs/qna/10-subject.md b/.docs/qna/10-subject.md new file mode 100644 index 000000000..83f849a5d --- /dev/null +++ b/.docs/qna/10-subject.md @@ -0,0 +1,237 @@ +## 🧭 루프팩 BE L2 - Round 10 + +> 서비스에서 다양한 가치를 창출하기 위해 대량의 데이터를 모으고, 쌓고, 압착해야 합니다. 데이터의 규모가 커지면, 점점 이런 작업들을 웹 애플리케이션 내에서 처리하는 것에 대한 부하가 가파르게 높아집니다. + +그래서 우리는 마지막으로 `spring-batch` 애플리케이션을 만들어 볼 거예요. 이를 기반으로 일간 랭킹 뿐 아닌 주간, 월간 랭킹 또한 집계를 활용해 만들어 봅시다. +> + + + +지난 라운드에서 Kafka Consumer 와 Redis ZSET 을 활용해 메세지를 압착해 처리량을 높이는 테크닉, 특정 점수 기준의 정렬 SET 활용 방법을 학습하고 실시간으로 갱신되는 일단위 랭킹을 만들어보았습니다. + +이번 라운드에서는 Spring Batch 를 이용해 주간, 월간 랭킹을 구현합니다. **Batch** 는 일간 집계를 기반으로 주간, 월간 집계를 만들어내고 **API** 는 일간 랭킹 뿐 아니라 주간, 월간 랭킹도 제공합니다. + + + +- Spring Batch (Job / Step / Chunk / Tasklet) +- ItemReader / ItemProcessor / ItemWriter +- Materialized View (사전 집계) +- 실시간 처리 vs 배치 처리 + + + +## 🧮 Bacth System + + + +### 🎞️ 실무에서 자주 보는 배치 시나리오 + +- **주문 정산** + - 주문/결제/환불 데이터를 모아 매일 새벽 3시 정산 테이블 생성. + - PG사 매출/정산 금액 검증도 함께. +- **랭킹/통계 적재** + - 일간/주간/월간 인기 상품 집계 + - 카테고리별 판매량 통계 +- **데이터 정리/청소** + - 만료된 쿠폰 삭제, 오래된 로그 제거, 캐시 초기화 +- **데이터 웨어하우스(DW) 적재** + - 서비스 DB → DW(BigQuery, Redshift 등) 로 적재 후 분석 + +### ⚖️ 실시간 vs 배치 트레이드오프 + +| 항목 | 실시간 처리 | 배치 처리 | +| --- | --- | --- | +| 장점 | 즉각 반영 → UX 좋음 | 대규모 집계, 비용 효율적 | +| 단점 | 인프라 복잡, 멱등성 관리 필요 | 지연 발생, 실시간성 부족 | +| 적합 | 좋아요 수, 실시간 랭킹 | 월간 리포트, 대시보드, BI | +| 초점 | **신속성** | **정확성 & 효율성** | + +--- + +## 🏗️ Spring Batch + +### 💧 **기본 구성 요소** + +- **Job** : 배치 실행 단위 (예: “일간 주문 통계 Job”) +- **Step** : Job 을 구성하는 세부 단계 + +### 📌 배치 처리 모델 + +**Chunk-Oriented Processing** + +- 데이터 읽기 (Reader) → 가공 (Processor) → 저장 (Writer) +- 청크 단위로 트랜잭션이 관리됨 → 안정적 대량 처리 + +```java +@Bean +public Step orderStatsStep( + JobRepository jobRepository, + PlatformTransactionManager txManager, + ItemReader reader, + ItemProcessor processor, + ItemWriter writer +) { + return new StepBuilder("orderStatsStep", jobRepository) + .chunk(1000, txManager) + .reader(reader) + .processor(processor) + .writer(writer) + .build(); +} +``` + +**장점** + +- 대규모 집계/정산/데이터 변환에 적합 +- 트랜잭션 단위 조절 가능 + +--- + +**Tasklet** + +- Step = 하나의 작업(Task) 실행 +- 반복 구조 없음, 단발성 작업에 적합 + +```java +@Bean +public Step cleanupStep( + JobRepository jobRepository, + PlatformTransactionManager txManager +) { + return new StepBuilder("cleanupStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + orderRepository.deleteOldOrders(); // 만료 주문 삭제 + return RepeatStatus.FINISHED; + }, txManager) + .build(); +} +``` + +**장점** + +- 간단한 SQL 실행, 파일 이동, 캐시 초기화 등에 적합 +- Reader/Processor/Writer 필요 없는 작업에 깔끔 + +> *일반적으로 **구현의 용이성** 등을 이유로 Tasklet 내에서 로직 상으로 Chunk Oriented Processing 을 구현하기도 합니다.* +> + +--- + +### 🗼 Materialized View + + + +- **복잡한 집계 쿼리를 미리 계산해둔 조회 전용 구조** +- MySQL 은 MV 기능이 별도로 없으므로 보통 **별도 테이블 + 배치 적재** 방식 사용 +- 주기적으로 대규모 데이터 (각 상품의 일별 일간 집계) 를 주기적으로 집계해 활용 + +```sql +CREATE TABLE product_metrics_weekly ( // 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonthWeek VARCHAR, // 예시입니다. + updated_at DATETIME +); + +CREATE TABLE product_metrics_monthly ( // 주간 상품 이벤트 집계 + product_id BIGINT PRIMARY KEY, + like_count INT, + order_count INT, + view_count INT, + yearMonth VARCHAR, // 예시입니다. + updated_at DATETIME +); +``` + +--- + +### 🎯 운영 관점에서의 배치 전략 + +- **스케줄링** : Spring Scheduler, Quartz 혹은 인프라 (Cron + K8s) +- **재실행 전략** : 실패 시 부분 롤백 vs 전체 재실행 +- **병렬 Step** : 여러 Step 을 동시에 실행해 성능 향상 +- **모니터링** : 실행 로그, 실패 알림, 처리 건수 추적 + +--- + + + +| 구분 | 링크 | +| --- | --- | +| 🔍 Spring Batch | [Spring Docs - Spring Batch](https://docs.spring.io/spring-batch/reference/) | +| ⚙ Spring Boot with Spring Batch | [Baeldung - Spring Boot with Spring Batch](https://www.baeldung.com/spring-boot-spring-batch) | +| 📖 Materialized View | [AWS - What is Materialized View](https://aws.amazon.com/ko/what-is/materialized-view/) | + + + +이번 10주 동안 우리는 **단순한 CRUD를 넘어서, 실제 서비스에서 마주치는 문제들을 단계적으로 풀어왔습니다**. 현업에서 여러분들이 활약하기 위해 어떤 것들을 알면 좋을지, 문제를 접근하고 해석하는 방법, 문제에 맞는 적절한 해답을 도출하는 방법 등을 전달하려고 노력했어요. + +- **1~3주차** : 도메인 모델링, 계층 분리, 객체 협력 설계 +- **4~6주차** : 트랜잭션과 동시성, 읽기 최적화, 외부 시스템(결제 PG) 연동과 회복 탄력성 +- **7주차** : 이벤트 와 Kafka, 유량제어 +- **8주차** : 대기열 큐 +- **9주차** : 실시간 집계, 랭킹 시스템 구축 +- **10주차** : 배치와 Materialized View를 통한 대규모 집계와 조회 최적화 + +즉, **이커머스라는 시나리오를 통해 → 설계 → 동시성 → 성능 → 회복력 → 이벤트 → 확장성 → 데이터 파이프라인 → 집계** 까지, 실무에서 다루는 거의 모든 챕터를 작은 스케일로 경험해 본 셈입니다. + +하지만 여기서 끝이 아닙니다. + +- 실제 서비스는 **더 많은 데이터와 트래픽, 더 복잡한 요구사항** 속에서 움직입니다. +- 새로운 기능을 추가할 때마다, 이번 과정에서 배운 **Trade-off와 선택의 기준**이 반복해서 필요합니다. +- 이직, 프로젝트, 사이드 개발 등 어떤 길을 가더라도, 지금 경험한 **문제 정의 → 분석 → 해결** 과정은 계속해서 쓰이게 될 것이고 힘이 되어줄 겁니다. + + + +이제는 여러분이 스스로 문제를 정의하고, 배운 도구와 방법을 적용하며, 더 깊은 학습으로 나아갈 차례입니다. + +루프팩 BE L2는 끝났지만, **여러분의 성장 여정은 여기서부터가 시작**입니다. \ No newline at end of file