From 39a01d7740bcd53a5aef58d44fd414826cde209f Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:19:32 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(ranking):=20MV=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=B0=8F=20JPA=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mv_product_rank_weekly, mv_product_rank_monthly 테이블 DDL 추가 - product_metrics_daily 테이블 DDL 추가 - ProductRankWeeklyJpaEntity, ProductRankMonthlyJpaEntity 구현 - Repository 인터페이스 및 구현체 추가 Co-Authored-By: Claude Opus 4.5 --- .../rank/ProductMetricsDailyJpaEntity.java | 128 +++++++++++++++++ .../jpa/rank/ProductRankMonthlyJpaEntity.java | 130 ++++++++++++++++++ .../rank/ProductRankMonthlyJpaRepository.java | 43 ++++++ .../jpa/rank/ProductRankMonthlyMapper.java | 39 ++++++ .../ProductRankMonthlyRepositoryImpl.java | 69 ++++++++++ .../jpa/rank/ProductRankWeeklyJpaEntity.java | 130 ++++++++++++++++++ .../rank/ProductRankWeeklyJpaRepository.java | 43 ++++++ .../jpa/rank/ProductRankWeeklyMapper.java | 39 ++++++ .../rank/ProductRankWeeklyRepositoryImpl.java | 69 ++++++++++ .../V002__create_product_rank_mv_tables.sql | 86 ++++++++++++ ...03__create_product_metrics_daily_table.sql | 39 ++++++ 11 files changed, 815 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductMetricsDailyJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyMapper.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyRepositoryImpl.java create mode 100644 scripts/migration/V002__create_product_rank_mv_tables.sql create mode 100644 scripts/migration/V003__create_product_metrics_daily_table.sql diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductMetricsDailyJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductMetricsDailyJpaEntity.java new file mode 100644 index 0000000000..d927ddc0d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductMetricsDailyJpaEntity.java @@ -0,0 +1,128 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 일간 상품 메트릭 JPA 엔티티. + * 주간/월간 랭킹 집계 배치 Job의 소스 테이블입니다. + */ +@Entity +@Table( + name = "product_metrics_daily", + indexes = { + @Index(name = "idx_daily_metric_date", columnList = "metric_date"), + @Index(name = "idx_daily_product", columnList = "product_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_daily_product_date", columnNames = {"product_id", "metric_date"}) + } +) +public class ProductMetricsDailyJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "metric_date", nullable = false) + private LocalDate metricDate; + + @Column(name = "view_count", nullable = false) + private long viewCount; + + @Column(name = "like_count", nullable = false) + private long likeCount; + + @Column(name = "order_count", nullable = false) + private long orderCount; + + @Column(name = "score", nullable = false, precision = 15, scale = 4) + private BigDecimal score; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected ProductMetricsDailyJpaEntity() {} + + public ProductMetricsDailyJpaEntity( + Long productId, + LocalDate metricDate, + long viewCount, + long likeCount, + long orderCount, + BigDecimal score + ) { + this.productId = productId; + this.metricDate = metricDate; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.orderCount = orderCount; + this.score = score; + } + + @PrePersist + private void prePersist() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public LocalDate getMetricDate() { + return metricDate; + } + + public long getViewCount() { + return viewCount; + } + + public long getLikeCount() { + return likeCount; + } + + public long getOrderCount() { + return orderCount; + } + + public BigDecimal getScore() { + return score; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaEntity.java new file mode 100644 index 0000000000..9290d882b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaEntity.java @@ -0,0 +1,130 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 월간 상품 랭킹 JPA 엔티티. + */ +@Entity +@Table( + name = "mv_product_rank_monthly", + indexes = { + @Index(name = "idx_monthly_period_rank", columnList = "period_start_date, rank_number"), + @Index(name = "idx_monthly_product", columnList = "product_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_monthly_period_product", columnNames = {"period_start_date", "product_id"}) + } +) +public class ProductRankMonthlyJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank_number", nullable = false) + private int rankNumber; + + @Column(name = "total_score", nullable = false, precision = 15, scale = 4) + private BigDecimal totalScore; + + @Column(name = "total_view_count", nullable = false) + private long totalViewCount; + + @Column(name = "total_like_count", nullable = false) + private long totalLikeCount; + + @Column(name = "total_order_count", nullable = false) + private long totalOrderCount; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ProductRankMonthlyJpaEntity() {} + + public ProductRankMonthlyJpaEntity( + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + this.productId = productId; + this.rankNumber = rankNumber; + this.totalScore = totalScore; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalOrderCount = totalOrderCount; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + } + + @PrePersist + private void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public int getRankNumber() { + return rankNumber; + } + + public BigDecimal getTotalScore() { + return totalScore; + } + + public long getTotalViewCount() { + return totalViewCount; + } + + public long getTotalLikeCount() { + return totalLikeCount; + } + + public long getTotalOrderCount() { + return totalOrderCount; + } + + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + + public LocalDate getPeriodEndDate() { + return periodEndDate; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..7262cbcaee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyJpaRepository.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 월간 상품 랭킹 JPA Repository. + */ +public interface ProductRankMonthlyJpaRepository extends JpaRepository { + + /** + * 특정 월간의 랭킹을 순위순으로 조회합니다. + */ + List findByPeriodStartDateOrderByRankNumberAsc(LocalDate periodStartDate); + + /** + * 특정 상품의 월간 랭킹 이력을 최신순으로 조회합니다. + */ + List findByProductIdOrderByPeriodStartDateDesc(Long productId); + + /** + * 특정 월간의 특정 상품 랭킹을 조회합니다. + */ + Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId); + + /** + * 특정 월간의 랭킹 데이터를 삭제합니다. + */ + @Modifying + @Query("DELETE FROM ProductRankMonthlyJpaEntity e WHERE e.periodStartDate = :periodStartDate") + void deleteByPeriodStartDate(@Param("periodStartDate") LocalDate periodStartDate); + + /** + * 특정 월간의 랭킹 데이터 존재 여부를 확인합니다. + */ + boolean existsByPeriodStartDate(LocalDate periodStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyMapper.java new file mode 100644 index 0000000000..035f1c479c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyMapper.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import com.loopers.batch.domain.ProductRankMonthly; +import org.springframework.stereotype.Component; + +/** + * 월간 상품 랭킹 도메인 ↔ JPA 엔티티 매퍼. + */ +@Component +public class ProductRankMonthlyMapper { + + public ProductRankMonthlyJpaEntity toJpaEntity(ProductRankMonthly domain) { + return new ProductRankMonthlyJpaEntity( + domain.getProductId(), + domain.getRankNumber(), + domain.getTotalScore(), + domain.getTotalViewCount(), + domain.getTotalLikeCount(), + domain.getTotalOrderCount(), + domain.getPeriodStartDate(), + domain.getPeriodEndDate() + ); + } + + public ProductRankMonthly toDomain(ProductRankMonthlyJpaEntity entity) { + return ProductRankMonthly.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getRankNumber(), + entity.getTotalScore(), + entity.getTotalViewCount(), + entity.getTotalLikeCount(), + entity.getTotalOrderCount(), + entity.getPeriodStartDate(), + entity.getPeriodEndDate(), + entity.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyRepositoryImpl.java new file mode 100644 index 0000000000..b0a5d0ac19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankMonthlyRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import com.loopers.batch.domain.ProductRankMonthly; +import com.loopers.batch.domain.ProductRankMonthlyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 월간 상품 랭킹 저장소 구현체. + */ +@Repository +@RequiredArgsConstructor +public class ProductRankMonthlyRepositoryImpl implements ProductRankMonthlyRepository { + + private final ProductRankMonthlyJpaRepository jpaRepository; + private final ProductRankMonthlyMapper mapper; + + @Override + public ProductRankMonthly save(ProductRankMonthly productRankMonthly) { + ProductRankMonthlyJpaEntity entity = mapper.toJpaEntity(productRankMonthly); + ProductRankMonthlyJpaEntity saved = jpaRepository.save(entity); + return mapper.toDomain(saved); + } + + @Override + public List saveAll(List productRankMonthlyList) { + List entities = productRankMonthlyList.stream() + .map(mapper::toJpaEntity) + .toList(); + List savedEntities = jpaRepository.saveAll(entities); + return savedEntities.stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public List findByPeriodStartDate(LocalDate periodStartDate) { + return jpaRepository.findByPeriodStartDateOrderByRankNumberAsc(periodStartDate).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public List findByProductIdOrderByPeriodStartDateDesc(Long productId) { + return jpaRepository.findByProductIdOrderByPeriodStartDateDesc(productId).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId) { + return jpaRepository.findByPeriodStartDateAndProductId(periodStartDate, productId) + .map(mapper::toDomain); + } + + @Override + public void deleteByPeriodStartDate(LocalDate periodStartDate) { + jpaRepository.deleteByPeriodStartDate(periodStartDate); + } + + @Override + public boolean existsByPeriodStartDate(LocalDate periodStartDate) { + return jpaRepository.existsByPeriodStartDate(periodStartDate); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaEntity.java new file mode 100644 index 0000000000..c3209c0dd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaEntity.java @@ -0,0 +1,130 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 주간 상품 랭킹 JPA 엔티티. + */ +@Entity +@Table( + name = "mv_product_rank_weekly", + indexes = { + @Index(name = "idx_weekly_period_rank", columnList = "period_start_date, rank_number"), + @Index(name = "idx_weekly_product", columnList = "product_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_weekly_period_product", columnNames = {"period_start_date", "product_id"}) + } +) +public class ProductRankWeeklyJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "rank_number", nullable = false) + private int rankNumber; + + @Column(name = "total_score", nullable = false, precision = 15, scale = 4) + private BigDecimal totalScore; + + @Column(name = "total_view_count", nullable = false) + private long totalViewCount; + + @Column(name = "total_like_count", nullable = false) + private long totalLikeCount; + + @Column(name = "total_order_count", nullable = false) + private long totalOrderCount; + + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + @Column(name = "period_end_date", nullable = false) + private LocalDate periodEndDate; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ProductRankWeeklyJpaEntity() {} + + public ProductRankWeeklyJpaEntity( + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + this.productId = productId; + this.rankNumber = rankNumber; + this.totalScore = totalScore; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalOrderCount = totalOrderCount; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + } + + @PrePersist + private void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public int getRankNumber() { + return rankNumber; + } + + public BigDecimal getTotalScore() { + return totalScore; + } + + public long getTotalViewCount() { + return totalViewCount; + } + + public long getTotalLikeCount() { + return totalLikeCount; + } + + public long getTotalOrderCount() { + return totalOrderCount; + } + + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + + public LocalDate getPeriodEndDate() { + return periodEndDate; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..294cdf4f26 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyJpaRepository.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 주간 상품 랭킹 JPA Repository. + */ +public interface ProductRankWeeklyJpaRepository extends JpaRepository { + + /** + * 특정 주간의 랭킹을 순위순으로 조회합니다. + */ + List findByPeriodStartDateOrderByRankNumberAsc(LocalDate periodStartDate); + + /** + * 특정 상품의 주간 랭킹 이력을 최신순으로 조회합니다. + */ + List findByProductIdOrderByPeriodStartDateDesc(Long productId); + + /** + * 특정 주간의 특정 상품 랭킹을 조회합니다. + */ + Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId); + + /** + * 특정 주간의 랭킹 데이터를 삭제합니다. + */ + @Modifying + @Query("DELETE FROM ProductRankWeeklyJpaEntity e WHERE e.periodStartDate = :periodStartDate") + void deleteByPeriodStartDate(@Param("periodStartDate") LocalDate periodStartDate); + + /** + * 특정 주간의 랭킹 데이터 존재 여부를 확인합니다. + */ + boolean existsByPeriodStartDate(LocalDate periodStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyMapper.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyMapper.java new file mode 100644 index 0000000000..2ce050ea19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyMapper.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import com.loopers.batch.domain.ProductRankWeekly; +import org.springframework.stereotype.Component; + +/** + * 주간 상품 랭킹 도메인 ↔ JPA 엔티티 매퍼. + */ +@Component +public class ProductRankWeeklyMapper { + + public ProductRankWeeklyJpaEntity toJpaEntity(ProductRankWeekly domain) { + return new ProductRankWeeklyJpaEntity( + domain.getProductId(), + domain.getRankNumber(), + domain.getTotalScore(), + domain.getTotalViewCount(), + domain.getTotalLikeCount(), + domain.getTotalOrderCount(), + domain.getPeriodStartDate(), + domain.getPeriodEndDate() + ); + } + + public ProductRankWeekly toDomain(ProductRankWeeklyJpaEntity entity) { + return ProductRankWeekly.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getRankNumber(), + entity.getTotalScore(), + entity.getTotalViewCount(), + entity.getTotalLikeCount(), + entity.getTotalOrderCount(), + entity.getPeriodStartDate(), + entity.getPeriodEndDate(), + entity.getCreatedAt() + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyRepositoryImpl.java new file mode 100644 index 0000000000..058a8499db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/rank/ProductRankWeeklyRepositoryImpl.java @@ -0,0 +1,69 @@ +package com.loopers.infrastructure.persistence.jpa.rank; + +import com.loopers.batch.domain.ProductRankWeekly; +import com.loopers.batch.domain.ProductRankWeeklyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 주간 상품 랭킹 저장소 구현체. + */ +@Repository +@RequiredArgsConstructor +public class ProductRankWeeklyRepositoryImpl implements ProductRankWeeklyRepository { + + private final ProductRankWeeklyJpaRepository jpaRepository; + private final ProductRankWeeklyMapper mapper; + + @Override + public ProductRankWeekly save(ProductRankWeekly productRankWeekly) { + ProductRankWeeklyJpaEntity entity = mapper.toJpaEntity(productRankWeekly); + ProductRankWeeklyJpaEntity saved = jpaRepository.save(entity); + return mapper.toDomain(saved); + } + + @Override + public List saveAll(List productRankWeeklyList) { + List entities = productRankWeeklyList.stream() + .map(mapper::toJpaEntity) + .toList(); + List savedEntities = jpaRepository.saveAll(entities); + return savedEntities.stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public List findByPeriodStartDate(LocalDate periodStartDate) { + return jpaRepository.findByPeriodStartDateOrderByRankNumberAsc(periodStartDate).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public List findByProductIdOrderByPeriodStartDateDesc(Long productId) { + return jpaRepository.findByProductIdOrderByPeriodStartDateDesc(productId).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId) { + return jpaRepository.findByPeriodStartDateAndProductId(periodStartDate, productId) + .map(mapper::toDomain); + } + + @Override + public void deleteByPeriodStartDate(LocalDate periodStartDate) { + jpaRepository.deleteByPeriodStartDate(periodStartDate); + } + + @Override + public boolean existsByPeriodStartDate(LocalDate periodStartDate) { + return jpaRepository.existsByPeriodStartDate(periodStartDate); + } +} \ No newline at end of file diff --git a/scripts/migration/V002__create_product_rank_mv_tables.sql b/scripts/migration/V002__create_product_rank_mv_tables.sql new file mode 100644 index 0000000000..a62d23997c --- /dev/null +++ b/scripts/migration/V002__create_product_rank_mv_tables.sql @@ -0,0 +1,86 @@ +-- ============================================================================ +-- V002: 주간/월간 상품 랭킹 Materialized View 테이블 생성 +-- ============================================================================ +-- +-- 설계 의사결정 기록: +-- +-- 1. period_start_date + period_end_date vs period_type ENUM +-- - 선택: period_start_date + period_end_date 별도 컬럼 +-- - 이유: +-- * 유연한 기간 정의 가능 (월별로 28~31일 가변, 윤년 등) +-- * 커스텀 기간 집계 확장 가능 (분기별, 이벤트 기간 등) +-- * WHERE 조건 직관적 (period_start_date = '2025-01-06') +-- - 트레이드오프: +-- * 저장 공간 약간 증가 (DATE 2개 vs ENUM 1개) +-- * period_type은 테이블 분리로 해결 (weekly/monthly 테이블 분리) +-- +-- 2. rank_number를 저장하는 이유 +-- - 조회 시 ORDER BY + LIMIT 연산 제거 → 읽기 최적화 +-- - 배치 Job에서 한 번만 정렬하고, API는 단순 조회만 수행 +-- - 순위 변동 이력 추적 가능 +-- +-- 3. TOP 100만 저장하는 이유 +-- - 저장 공간 절약 (주간: 52주 × 100건 = 5,200건/년) +-- - 실제 서비스에서 100위 밖은 노출하지 않음 +-- - 필요 시 TOP N 확장 용이 (테이블 구조 변경 불필요) +-- +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- 주간 TOP 100 랭킹 테이블 +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT NOT NULL AUTO_INCREMENT, + product_id BIGINT NOT NULL COMMENT '상품 ID', + rank_number INT NOT NULL COMMENT '해당 주간 순위 (1~100)', + total_score DECIMAL(15, 4) NOT NULL DEFAULT 0 COMMENT '주간 합산 점수', + total_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '주간 조회수 합계', + total_like_count BIGINT NOT NULL DEFAULT 0 COMMENT '주간 좋아요수 합계', + total_order_count BIGINT NOT NULL DEFAULT 0 COMMENT '주간 주문수 합계', + period_start_date DATE NOT NULL COMMENT '집계 시작일 (월요일)', + period_end_date DATE NOT NULL COMMENT '집계 종료일 (일요일)', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '레코드 생성 시각', + + PRIMARY KEY (id), + + -- 특정 주간의 랭킹 조회 최적화 (가장 빈번한 쿼리 패턴) + -- SELECT * FROM mv_product_rank_weekly WHERE period_start_date = ? ORDER BY rank_number + INDEX idx_weekly_period_rank (period_start_date, rank_number), + + -- 특정 상품의 주간 랭킹 이력 조회 + -- SELECT * FROM mv_product_rank_weekly WHERE product_id = ? ORDER BY period_start_date DESC + INDEX idx_weekly_product (product_id), + + -- 동일 주간에 동일 상품 중복 방지 + UNIQUE INDEX uk_weekly_period_product (period_start_date, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='주간 상품 랭킹 TOP 100 (Materialized View)'; + + +-- ---------------------------------------------------------------------------- +-- 월간 TOP 100 랭킹 테이블 +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT, + product_id BIGINT NOT NULL COMMENT '상품 ID', + rank_number INT NOT NULL COMMENT '해당 월간 순위 (1~100)', + total_score DECIMAL(15, 4) NOT NULL DEFAULT 0 COMMENT '월간 합산 점수', + total_view_count BIGINT NOT NULL DEFAULT 0 COMMENT '월간 조회수 합계', + total_like_count BIGINT NOT NULL DEFAULT 0 COMMENT '월간 좋아요수 합계', + total_order_count BIGINT NOT NULL DEFAULT 0 COMMENT '월간 주문수 합계', + period_start_date DATE NOT NULL COMMENT '집계 시작일 (매월 1일)', + period_end_date DATE NOT NULL COMMENT '집계 종료일 (매월 말일)', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '레코드 생성 시각', + + PRIMARY KEY (id), + + -- 특정 월간의 랭킹 조회 최적화 + INDEX idx_monthly_period_rank (period_start_date, rank_number), + + -- 특정 상품의 월간 랭킹 이력 조회 + INDEX idx_monthly_product (product_id), + + -- 동일 월간에 동일 상품 중복 방지 + UNIQUE INDEX uk_monthly_period_product (period_start_date, product_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='월간 상품 랭킹 TOP 100 (Materialized View)'; \ No newline at end of file diff --git a/scripts/migration/V003__create_product_metrics_daily_table.sql b/scripts/migration/V003__create_product_metrics_daily_table.sql new file mode 100644 index 0000000000..23851e8257 --- /dev/null +++ b/scripts/migration/V003__create_product_metrics_daily_table.sql @@ -0,0 +1,39 @@ +-- ============================================================================ +-- V003: 일간 상품 메트릭 테이블 생성 +-- ============================================================================ +-- +-- 목적: +-- - 일별 상품 메트릭 스냅샷 저장 +-- - 주간/월간 랭킹 집계 배치 Job의 소스 테이블 +-- - 기존 product_metrics (누적 테이블)와 별도로 일간 단위 데이터 관리 +-- +-- 데이터 적재 방식: +-- - 매일 자정 배치 Job이 Redis ZSET (ranking:all:{yyyyMMdd})에서 데이터를 읽어 적재 +-- - 또는 이벤트 발생 시 실시간으로 upsert +-- +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS product_metrics_daily ( + id BIGINT NOT NULL AUTO_INCREMENT, + product_id BIGINT NOT NULL COMMENT '상품 ID', + metric_date DATE NOT NULL COMMENT '메트릭 날짜', + view_count BIGINT NOT NULL DEFAULT 0 COMMENT '해당일 조회수', + like_count BIGINT NOT NULL DEFAULT 0 COMMENT '해당일 좋아요수', + order_count BIGINT NOT NULL DEFAULT 0 COMMENT '해당일 주문수', + score DECIMAL(15, 4) NOT NULL DEFAULT 0 COMMENT '해당일 랭킹 점수 (view*0.1 + like*0.2 + order*0.7)', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '레코드 생성 시각', + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '레코드 수정 시각', + + PRIMARY KEY (id), + + -- 특정 날짜 범위의 메트릭 조회 (주간/월간 집계용) + -- SELECT ... FROM product_metrics_daily WHERE metric_date BETWEEN ? AND ? GROUP BY product_id + INDEX idx_daily_metric_date (metric_date), + + -- 특정 상품의 일별 메트릭 이력 조회 + INDEX idx_daily_product (product_id), + + -- 동일 날짜에 동일 상품 중복 방지 + UNIQUE INDEX uk_daily_product_date (product_id, metric_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +COMMENT='일간 상품 메트릭 (주간/월간 랭킹 집계용)'; \ No newline at end of file From 5eb3516ad9ece01038907b120d0a2307141b94aa Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:19:41 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(batch):=20Spring=20Batch=20=EC=A3=BC?= =?UTF-8?q?=EA=B0=84/=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20Job=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spring Batch 의존성 및 설정 추가 (batch.yml) - 공통 클래스: RankingJobConstants, RankingMetricsAggregation - WeeklyRankingJobConfig: 주간 랭킹 집계 배치 Job - MonthlyRankingJobConfig: 월간 랭킹 집계 배치 Job - RankingJobScheduler: 운영용 스케줄러 (cron 기반) - BatchSchedulerProperties: 스케줄러 설정 Chunk-Oriented Processing 패턴 적용: - Reader: JdbcCursorItemReader (GROUP BY 집계) - Processor: rank_number 부여 + Entity 변환 - Writer: DELETE + INSERT (멱등성 보장) Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/build.gradle.kts | 6 + .../com/loopers/batch/config/BatchConfig.java | 21 +++ .../batch/domain/ProductRankMonthly.java | 136 +++++++++++++++ .../domain/ProductRankMonthlyRepository.java | 53 ++++++ .../batch/domain/ProductRankWeekly.java | 136 +++++++++++++++ .../domain/ProductRankWeeklyRepository.java | 53 ++++++ .../batch/job/common/RankingJobConstants.java | 42 +++++ .../job/common/RankingMetricsAggregation.java | 35 ++++ .../MonthlyRankingJobConfig.java | 157 ++++++++++++++++++ .../WeeklyMetricsAggregation.java | 16 ++ .../weeklyranking/WeeklyRankingJobConfig.java | 137 +++++++++++++++ .../scheduler/BatchSchedulerProperties.java | 26 +++ .../batch/scheduler/RankingJobScheduler.java | 87 ++++++++++ .../commerce-api/src/main/resources/batch.yml | 60 +++++++ 14 files changed, 965 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/config/BatchConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthlyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeekly.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeeklyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingJobConstants.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingMetricsAggregation.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyMetricsAggregation.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/scheduler/BatchSchedulerProperties.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/batch/scheduler/RankingJobScheduler.java create mode 100644 apps/commerce-api/src/main/resources/batch.yml diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 027847bea4..c9cc49119b 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") // resilience4j @@ -28,4 +31,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // batch test + testImplementation("org.springframework.batch:spring-batch-test") } diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/config/BatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/batch/config/BatchConfig.java new file mode 100644 index 0000000000..7224947519 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/config/BatchConfig.java @@ -0,0 +1,21 @@ +package com.loopers.batch.config; + +import org.springframework.context.annotation.Configuration; + +/** + * Spring Batch 설정 클래스. + * + * Spring Boot 3.x + Spring Batch 5.x 기준: + * - Spring Boot의 자동 구성을 활용하여 Batch 인프라 빈 자동 구성 + * - @Primary DataSource와 기본 TransactionManager 사용 + * - 메타 테이블(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION 등)은 + * spring.batch.jdbc.initialize-schema 설정에 따라 자동 생성 + * + * 주의: @EnableBatchProcessing을 사용하면 Spring Boot의 자동 구성이 비활성화되어 + * 스키마 초기화가 동작하지 않습니다. 따라서 자동 구성에 의존합니다. + */ +@Configuration +public class BatchConfig { + // Spring Boot 자동 구성 사용 + // 필요한 경우 여기에 커스텀 Job, Step 빈을 정의합니다. +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthly.java b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthly.java new file mode 100644 index 0000000000..9d3f9feee2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthly.java @@ -0,0 +1,136 @@ +package com.loopers.batch.domain; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 월간 상품 랭킹 도메인 모델. + * 매월 배치 Job에 의해 생성되며, TOP 100 상품의 월간 집계 데이터를 보관합니다. + */ +public class ProductRankMonthly { + + private final Long id; + private final Long productId; + private final int rankNumber; + private final BigDecimal totalScore; + private final long totalViewCount; + private final long totalLikeCount; + private final long totalOrderCount; + private final LocalDate periodStartDate; + private final LocalDate periodEndDate; + private final LocalDateTime createdAt; + + private ProductRankMonthly( + Long id, + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate, + LocalDateTime createdAt + ) { + this.id = id; + this.productId = productId; + this.rankNumber = rankNumber; + this.totalScore = totalScore; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalOrderCount = totalOrderCount; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.createdAt = createdAt; + } + + /** + * 새로운 월간 랭킹 엔트리를 생성합니다. + */ + public static ProductRankMonthly create( + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + return new ProductRankMonthly( + null, + productId, + rankNumber, + totalScore, + totalViewCount, + totalLikeCount, + totalOrderCount, + periodStartDate, + periodEndDate, + LocalDateTime.now() + ); + } + + /** + * 영속성 계층에서 도메인 객체를 복원합니다. + */ + public static ProductRankMonthly reconstitute( + Long id, + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate, + LocalDateTime createdAt + ) { + return new ProductRankMonthly( + id, productId, rankNumber, totalScore, + totalViewCount, totalLikeCount, totalOrderCount, + periodStartDate, periodEndDate, createdAt + ); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public int getRankNumber() { + return rankNumber; + } + + public BigDecimal getTotalScore() { + return totalScore; + } + + public long getTotalViewCount() { + return totalViewCount; + } + + public long getTotalLikeCount() { + return totalLikeCount; + } + + public long getTotalOrderCount() { + return totalOrderCount; + } + + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + + public LocalDate getPeriodEndDate() { + return periodEndDate; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthlyRepository.java b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthlyRepository.java new file mode 100644 index 0000000000..7a1276abf2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankMonthlyRepository.java @@ -0,0 +1,53 @@ +package com.loopers.batch.domain; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 월간 상품 랭킹 저장소 인터페이스. + */ +public interface ProductRankMonthlyRepository { + + /** + * 월간 랭킹 엔트리를 저장합니다. + */ + ProductRankMonthly save(ProductRankMonthly productRankMonthly); + + /** + * 월간 랭킹 엔트리를 일괄 저장합니다. + */ + List saveAll(List productRankMonthlyList); + + /** + * 특정 월간의 랭킹을 조회합니다. + * + * @param periodStartDate 월간 시작일 (매월 1일) + * @return 해당 월간의 TOP 100 랭킹 목록 (순위순 정렬) + */ + List findByPeriodStartDate(LocalDate periodStartDate); + + /** + * 특정 상품의 월간 랭킹 이력을 조회합니다. + * + * @param productId 상품 ID + * @return 해당 상품의 월간 랭킹 이력 (최신순 정렬) + */ + List findByProductIdOrderByPeriodStartDateDesc(Long productId); + + /** + * 특정 월간의 특정 상품 랭킹을 조회합니다. + */ + Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId); + + /** + * 특정 월간의 랭킹 데이터를 삭제합니다. + * (재집계 시 기존 데이터 삭제용) + */ + void deleteByPeriodStartDate(LocalDate periodStartDate); + + /** + * 특정 월간의 랭킹 데이터 존재 여부를 확인합니다. + */ + boolean existsByPeriodStartDate(LocalDate periodStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeekly.java b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeekly.java new file mode 100644 index 0000000000..83610dbd4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeekly.java @@ -0,0 +1,136 @@ +package com.loopers.batch.domain; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 주간 상품 랭킹 도메인 모델. + * 매주 배치 Job에 의해 생성되며, TOP 100 상품의 주간 집계 데이터를 보관합니다. + */ +public class ProductRankWeekly { + + private final Long id; + private final Long productId; + private final int rankNumber; + private final BigDecimal totalScore; + private final long totalViewCount; + private final long totalLikeCount; + private final long totalOrderCount; + private final LocalDate periodStartDate; + private final LocalDate periodEndDate; + private final LocalDateTime createdAt; + + private ProductRankWeekly( + Long id, + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate, + LocalDateTime createdAt + ) { + this.id = id; + this.productId = productId; + this.rankNumber = rankNumber; + this.totalScore = totalScore; + this.totalViewCount = totalViewCount; + this.totalLikeCount = totalLikeCount; + this.totalOrderCount = totalOrderCount; + this.periodStartDate = periodStartDate; + this.periodEndDate = periodEndDate; + this.createdAt = createdAt; + } + + /** + * 새로운 주간 랭킹 엔트리를 생성합니다. + */ + public static ProductRankWeekly create( + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate + ) { + return new ProductRankWeekly( + null, + productId, + rankNumber, + totalScore, + totalViewCount, + totalLikeCount, + totalOrderCount, + periodStartDate, + periodEndDate, + LocalDateTime.now() + ); + } + + /** + * 영속성 계층에서 도메인 객체를 복원합니다. + */ + public static ProductRankWeekly reconstitute( + Long id, + Long productId, + int rankNumber, + BigDecimal totalScore, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + LocalDate periodStartDate, + LocalDate periodEndDate, + LocalDateTime createdAt + ) { + return new ProductRankWeekly( + id, productId, rankNumber, totalScore, + totalViewCount, totalLikeCount, totalOrderCount, + periodStartDate, periodEndDate, createdAt + ); + } + + public Long getId() { + return id; + } + + public Long getProductId() { + return productId; + } + + public int getRankNumber() { + return rankNumber; + } + + public BigDecimal getTotalScore() { + return totalScore; + } + + public long getTotalViewCount() { + return totalViewCount; + } + + public long getTotalLikeCount() { + return totalLikeCount; + } + + public long getTotalOrderCount() { + return totalOrderCount; + } + + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + + public LocalDate getPeriodEndDate() { + return periodEndDate; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeeklyRepository.java b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeeklyRepository.java new file mode 100644 index 0000000000..fed47df4e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/domain/ProductRankWeeklyRepository.java @@ -0,0 +1,53 @@ +package com.loopers.batch.domain; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * 주간 상품 랭킹 저장소 인터페이스. + */ +public interface ProductRankWeeklyRepository { + + /** + * 주간 랭킹 엔트리를 저장합니다. + */ + ProductRankWeekly save(ProductRankWeekly productRankWeekly); + + /** + * 주간 랭킹 엔트리를 일괄 저장합니다. + */ + List saveAll(List productRankWeeklyList); + + /** + * 특정 주간의 랭킹을 조회합니다. + * + * @param periodStartDate 주간 시작일 (월요일) + * @return 해당 주간의 TOP 100 랭킹 목록 (순위순 정렬) + */ + List findByPeriodStartDate(LocalDate periodStartDate); + + /** + * 특정 상품의 주간 랭킹 이력을 조회합니다. + * + * @param productId 상품 ID + * @return 해당 상품의 주간 랭킹 이력 (최신순 정렬) + */ + List findByProductIdOrderByPeriodStartDateDesc(Long productId); + + /** + * 특정 주간의 특정 상품 랭킹을 조회합니다. + */ + Optional findByPeriodStartDateAndProductId(LocalDate periodStartDate, Long productId); + + /** + * 특정 주간의 랭킹 데이터를 삭제합니다. + * (재집계 시 기존 데이터 삭제용) + */ + void deleteByPeriodStartDate(LocalDate periodStartDate); + + /** + * 특정 주간의 랭킹 데이터 존재 여부를 확인합니다. + */ + boolean existsByPeriodStartDate(LocalDate periodStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingJobConstants.java b/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingJobConstants.java new file mode 100644 index 0000000000..a643c6abc1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingJobConstants.java @@ -0,0 +1,42 @@ +package com.loopers.batch.job.common; + +import java.time.format.DateTimeFormatter; + +/** + * 랭킹 집계 Job 공통 상수. + */ +public final class RankingJobConstants { + + private RankingJobConstants() {} + + public static final int CHUNK_SIZE = 100; + public static final int TOP_N = 100; + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 메트릭 집계 SQL 템플릿. + * {startDate}, {endDate}, {limit}을 치환하여 사용합니다. + */ + public static final String AGGREGATION_SQL_TEMPLATE = """ + SELECT product_id, + SUM(view_count) as total_view_count, + SUM(like_count) as total_like_count, + SUM(order_count) as total_order_count, + SUM(score) as total_score + FROM product_metrics_daily + WHERE metric_date BETWEEN '{startDate}' AND '{endDate}' + GROUP BY product_id + ORDER BY total_score DESC + LIMIT {limit} + """; + + /** + * 날짜 범위로 집계 SQL을 생성합니다. + */ + public static String buildAggregationSql(String startDate, String endDate) { + return AGGREGATION_SQL_TEMPLATE + .replace("{startDate}", startDate) + .replace("{endDate}", endDate) + .replace("{limit}", String.valueOf(TOP_N)); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingMetricsAggregation.java b/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingMetricsAggregation.java new file mode 100644 index 0000000000..25acbf5411 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/job/common/RankingMetricsAggregation.java @@ -0,0 +1,35 @@ +package com.loopers.batch.job.common; + +import org.springframework.jdbc.core.RowMapper; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * 랭킹 메트릭 집계 결과 DTO. + * 주간/월간 랭킹 집계 Job에서 공통으로 사용합니다. + */ +public record RankingMetricsAggregation( + Long productId, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + BigDecimal totalScore +) { + /** + * ResultSet을 RankingMetricsAggregation으로 변환하는 RowMapper. + */ + public static class RankingMetricsRowMapper implements RowMapper { + @Override + public RankingMetricsAggregation mapRow(ResultSet rs, int rowNum) throws SQLException { + return new RankingMetricsAggregation( + rs.getLong("product_id"), + rs.getLong("total_view_count"), + rs.getLong("total_like_count"), + rs.getLong("total_order_count"), + rs.getBigDecimal("total_score") + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..a67e112fa0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobConfig.java @@ -0,0 +1,157 @@ +package com.loopers.batch.job.monthlyranking; + +import com.loopers.batch.job.common.RankingJobConstants; +import com.loopers.batch.job.common.RankingMetricsAggregation; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 월간 랭킹 집계 Batch Job 설정. + * + *

Job 파라미터: + *

    + *
  • targetDate (yyyyMMdd) - 이 날짜가 속한 월의 1일~말일 범위를 집계
  • + *
+ * + *

처리 흐름: + *

    + *
  1. Reader: product_metrics_daily에서 해당 월간 데이터를 GROUP BY로 집계하여 읽기
  2. + *
  3. Processor: rank_number 부여 및 Entity 변환
  4. + *
  5. Writer: 기존 데이터 DELETE 후 INSERT (멱등성 보장)
  6. + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + + private static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_NAME = "monthlyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final ProductRankMonthlyJpaRepository monthlyRankRepository; + + @Bean + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep()) + .build(); + } + + @Bean + @JobScope + public Step monthlyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(RankingJobConstants.CHUNK_SIZE, transactionManager) + .reader(monthlyMetricsReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .build(); + } + + /** + * Reader: 월간 메트릭 집계 데이터를 읽습니다. + * GROUP BY product_id로 집계하고 score DESC로 정렬하여 TOP 100을 가져옵니다. + */ + @Bean + @StepScope + public JdbcCursorItemReader monthlyMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + YearMonth yearMonth = YearMonth.from(target); + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + log.info("Reading monthly metrics: targetDate={}, monthStart={}, monthEnd={}", targetDate, monthStart, monthEnd); + + String sql = RankingJobConstants.buildAggregationSql(monthStart.toString(), monthEnd.toString()); + + return new JdbcCursorItemReaderBuilder() + .name("monthlyMetricsReader") + .dataSource(dataSource) + .sql(sql) + .rowMapper(new RankingMetricsAggregation.RankingMetricsRowMapper()) + .build(); + } + + /** + * Processor: 집계 데이터에 rank_number를 부여하고 Entity로 변환합니다. + */ + @Bean + @StepScope + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + YearMonth yearMonth = YearMonth.from(target); + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + AtomicInteger rankCounter = new AtomicInteger(0); + + return aggregation -> { + int rankNumber = rankCounter.incrementAndGet(); + return new ProductRankMonthlyJpaEntity( + aggregation.productId(), + rankNumber, + aggregation.totalScore(), + aggregation.totalViewCount(), + aggregation.totalLikeCount(), + aggregation.totalOrderCount(), + monthStart, + monthEnd + ); + }; + } + + /** + * Writer: 기존 월간 데이터를 삭제하고 새 데이터를 저장합니다. + * DELETE + INSERT로 멱등성을 보장합니다. + */ + @Bean + @StepScope + public ItemWriter monthlyRankingWriter( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + YearMonth yearMonth = YearMonth.from(target); + LocalDate monthStart = yearMonth.atDay(1); + + return items -> { + if (!items.getItems().isEmpty()) { + log.info("Deleting existing monthly ranking data: monthStart={}", monthStart); + monthlyRankRepository.deleteByPeriodStartDate(monthStart); + } + + log.info("Saving {} monthly ranking records for monthStart={}", items.size(), monthStart); + monthlyRankRepository.saveAll(items.getItems()); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyMetricsAggregation.java b/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyMetricsAggregation.java new file mode 100644 index 0000000000..30491405dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyMetricsAggregation.java @@ -0,0 +1,16 @@ +package com.loopers.batch.job.weeklyranking; + +import java.math.BigDecimal; + +/** + * 주간 메트릭 집계 결과 DTO. + * Reader에서 GROUP BY 쿼리 결과를 담는 용도입니다. + */ +public record WeeklyMetricsAggregation( + Long productId, + long totalViewCount, + long totalLikeCount, + long totalOrderCount, + BigDecimal totalScore +) { +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..b18e05ccac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobConfig.java @@ -0,0 +1,137 @@ +package com.loopers.batch.job.weeklyranking; + +import com.loopers.batch.job.common.RankingJobConstants; +import com.loopers.batch.job.common.RankingMetricsAggregation; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 주간 랭킹 집계 Batch Job 설정. + * + *

Job 파라미터: + *

    + *
  • targetDate (yyyyMMdd) - 이 날짜가 속한 주의 월~일 범위를 집계
  • + *
+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class WeeklyRankingJobConfig { + + private static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_NAME = "weeklyRankingStep"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final ProductRankWeeklyJpaRepository weeklyRankRepository; + + @Bean + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep()) + .build(); + } + + @Bean + @JobScope + public Step weeklyRankingStep() { + return new StepBuilder(STEP_NAME, jobRepository) + .chunk(RankingJobConstants.CHUNK_SIZE, transactionManager) + .reader(weeklyMetricsReader(null)) + .processor(weeklyRankingProcessor(null)) + .writer(weeklyRankingWriter(null)) + .build(); + } + + @Bean + @StepScope + public JdbcCursorItemReader weeklyMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + LocalDate weekStart = target.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekEnd = target.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + + log.info("Reading weekly metrics: targetDate={}, weekStart={}, weekEnd={}", targetDate, weekStart, weekEnd); + + String sql = RankingJobConstants.buildAggregationSql(weekStart.toString(), weekEnd.toString()); + + return new JdbcCursorItemReaderBuilder() + .name("weeklyMetricsReader") + .dataSource(dataSource) + .sql(sql) + .rowMapper(new RankingMetricsAggregation.RankingMetricsRowMapper()) + .build(); + } + + @Bean + @StepScope + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + LocalDate weekStart = target.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate weekEnd = target.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + + AtomicInteger rankCounter = new AtomicInteger(0); + + return aggregation -> { + int rankNumber = rankCounter.incrementAndGet(); + return new ProductRankWeeklyJpaEntity( + aggregation.productId(), + rankNumber, + aggregation.totalScore(), + aggregation.totalViewCount(), + aggregation.totalLikeCount(), + aggregation.totalOrderCount(), + weekStart, + weekEnd + ); + }; + } + + @Bean + @StepScope + public ItemWriter weeklyRankingWriter( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate target = LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + LocalDate weekStart = target.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + return items -> { + if (!items.getItems().isEmpty()) { + log.info("Deleting existing weekly ranking data: weekStart={}", weekStart); + weeklyRankRepository.deleteByPeriodStartDate(weekStart); + } + + log.info("Saving {} weekly ranking records for weekStart={}", items.size(), weekStart); + weeklyRankRepository.saveAll(items.getItems()); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/BatchSchedulerProperties.java b/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/BatchSchedulerProperties.java new file mode 100644 index 0000000000..7c962508ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/BatchSchedulerProperties.java @@ -0,0 +1,26 @@ +package com.loopers.batch.scheduler; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Batch Scheduler 설정 프로퍼티. + * + * @param enabled 스케줄러 활성화 여부 + * @param weeklyCron 주간 랭킹 Job 실행 cron (기본: 매주 월요일 새벽 2시) + * @param monthlyCron 월간 랭킹 Job 실행 cron (기본: 매월 1일 새벽 3시) + */ +@ConfigurationProperties(prefix = "batch.scheduler") +public record BatchSchedulerProperties( + boolean enabled, + String weeklyCron, + String monthlyCron +) { + public BatchSchedulerProperties { + if (weeklyCron == null) { + weeklyCron = "0 0 2 ? * MON"; + } + if (monthlyCron == null) { + monthlyCron = "0 0 3 1 * ?"; + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/RankingJobScheduler.java b/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/RankingJobScheduler.java new file mode 100644 index 0000000000..2768eaa241 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/batch/scheduler/RankingJobScheduler.java @@ -0,0 +1,87 @@ +package com.loopers.batch.scheduler; + +import com.loopers.batch.job.common.RankingJobConstants; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** + * 랭킹 집계 Job 스케줄러. + * + *

주간 랭킹: 매주 월요일 새벽 2시 실행 (전주 월~일 집계) + *

월간 랭킹: 매월 1일 새벽 3시 실행 (전월 1일~말일 집계) + */ +@Slf4j +@Component +@RequiredArgsConstructor +@EnableConfigurationProperties(BatchSchedulerProperties.class) +public class RankingJobScheduler { + + private final JobLauncher jobLauncher; + private final BatchSchedulerProperties properties; + + @Qualifier("weeklyRankingJob") + private final Job weeklyRankingJob; + + @Qualifier("monthlyRankingJob") + private final Job monthlyRankingJob; + + /** + * 주간 랭킹 집계 스케줄. + * 매주 월요일 새벽 2시에 전주(월~일)의 데이터를 집계합니다. + */ + @Scheduled(cron = "${batch.scheduler.weekly-cron:0 0 2 ? * MON}") + public void runWeeklyRankingJob() { + if (!properties.enabled()) { + log.debug("Batch scheduler is disabled. Skipping weekly ranking job."); + return; + } + + LocalDate yesterday = LocalDate.now().minusDays(1); + String targetDate = yesterday.format(RankingJobConstants.DATE_FORMATTER); + + log.info("Starting scheduled weekly ranking job: targetDate={}", targetDate); + launchJob(weeklyRankingJob, targetDate); + } + + /** + * 월간 랭킹 집계 스케줄. + * 매월 1일 새벽 3시에 전월(1일~말일)의 데이터를 집계합니다. + */ + @Scheduled(cron = "${batch.scheduler.monthly-cron:0 0 3 1 * ?}") + public void runMonthlyRankingJob() { + if (!properties.enabled()) { + log.debug("Batch scheduler is disabled. Skipping monthly ranking job."); + return; + } + + LocalDate yesterday = LocalDate.now().minusDays(1); + String targetDate = yesterday.format(RankingJobConstants.DATE_FORMATTER); + + log.info("Starting scheduled monthly ranking job: targetDate={}", targetDate); + launchJob(monthlyRankingJob, targetDate); + } + + private void launchJob(Job job, String targetDate) { + try { + JobParameters params = new JobParametersBuilder() + .addString("targetDate", targetDate) + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + var execution = jobLauncher.run(job, params); + log.info("Scheduled job completed: {}, status: {}", job.getName(), execution.getStatus()); + } catch (Exception e) { + log.error("Failed to run scheduled job: {}", job.getName(), e); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/resources/batch.yml b/apps/commerce-api/src/main/resources/batch.yml new file mode 100644 index 0000000000..b02e04e170 --- /dev/null +++ b/apps/commerce-api/src/main/resources/batch.yml @@ -0,0 +1,60 @@ +spring: + batch: + jdbc: + initialize-schema: always + job: + enabled: false # 애플리케이션 시작 시 Job 자동 실행 비활성화 + +batch: + scheduler: + enabled: false # 기본값: 스케줄러 비활성화 + weekly-cron: "0 0 2 ? * MON" # 매주 월요일 새벽 2시 + monthly-cron: "0 0 3 1 * ?" # 매월 1일 새벽 3시 + +--- +spring.config.activate.on-profile: local + +spring: + batch: + jdbc: + initialize-schema: always + +batch: + scheduler: + enabled: false # 로컬에서는 수동 실행 + +--- +spring.config.activate.on-profile: test + +spring: + batch: + jdbc: + initialize-schema: always + +batch: + scheduler: + enabled: false # 테스트에서는 스케줄러 비활성화 + +--- +spring.config.activate.on-profile: dev + +spring: + batch: + jdbc: + initialize-schema: always + +batch: + scheduler: + enabled: false # 개발 환경에서는 수동 실행 + +--- +spring.config.activate.on-profile: qa, prd + +spring: + batch: + jdbc: + initialize-schema: never # 운영 환경에서는 수동 스키마 관리 + +batch: + scheduler: + enabled: true # 운영 환경에서만 스케줄러 활성화 \ No newline at end of file From 1735f70b921e97d52871a075a466db1129f10b35 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:19:51 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(batch):=20=EB=B0=B0=EC=B9=98=20Job=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20Admin=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BatchAdminV1Controller: REST API로 배치 Job 트리거 - POST /api-admin/v1/batch/weekly-ranking - POST /api-admin/v1/batch/monthly-ranking - BatchAdminV1Dto: JobExecution 응답 DTO - ErrorType 추가: BATCH_INVALID_DATE_FORMAT, BATCH_JOB_FAILED - HTTP 요청 예시 파일 추가 Co-Authored-By: Claude Opus 4.5 --- .../api/batch/BatchAdminV1ApiSpec.java | 36 +++++++ .../api/batch/BatchAdminV1Controller.java | 95 +++++++++++++++++++ .../interfaces/api/batch/BatchAdminV1Dto.java | 39 ++++++++ .../com/loopers/support/error/ErrorType.java | 6 +- http/batch-admin-api.http | 16 ++++ 5 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Dto.java create mode 100644 http/batch-admin-api.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1ApiSpec.java new file mode 100644 index 0000000000..dbdd3e939c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1ApiSpec.java @@ -0,0 +1,36 @@ +package com.loopers.interfaces.api.batch; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Batch Admin V1 API", description = "배치 Job 실행 관리 API입니다.") +public interface BatchAdminV1ApiSpec { + + @Operation( + summary = "주간 랭킹 집계 Job 실행", + description = """ + 주간 랭킹 집계 배치 Job을 실행합니다. + targetDate가 속한 주(월~일)의 product_metrics_daily 데이터를 집계하여 + TOP 100 랭킹을 생성합니다. + """ + ) + ApiResponse runWeeklyRankingJob( + @Parameter(description = "집계 대상 날짜 (yyyyMMdd 형식)", example = "20250414") + String targetDate + ); + + @Operation( + summary = "월간 랭킹 집계 Job 실행", + description = """ + 월간 랭킹 집계 배치 Job을 실행합니다. + targetDate가 속한 월(1일~말일)의 product_metrics_daily 데이터를 집계하여 + TOP 100 랭킹을 생성합니다. + """ + ) + ApiResponse runMonthlyRankingJob( + @Parameter(description = "집계 대상 날짜 (yyyyMMdd 형식)", example = "20250401") + String targetDate + ); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Controller.java new file mode 100644 index 0000000000..7495ed5053 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Controller.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.batch; + +import com.loopers.batch.job.common.RankingJobConstants; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +/** + * Batch Job 실행 Admin API Controller. + */ +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/batch") +public class BatchAdminV1Controller implements BatchAdminV1ApiSpec { + + private final JobLauncher jobLauncher; + + @Qualifier("weeklyRankingJob") + private final Job weeklyRankingJob; + + @Qualifier("monthlyRankingJob") + private final Job monthlyRankingJob; + + @PostMapping("/weekly-ranking") + @ResponseStatus(HttpStatus.OK) + @Override + public ApiResponse runWeeklyRankingJob( + @RequestParam String targetDate + ) { + validateTargetDateFormat(targetDate); + JobExecution execution = launchJob(weeklyRankingJob, targetDate); + return ApiResponse.success(BatchAdminV1Dto.JobExecutionResponse.from(execution)); + } + + @PostMapping("/monthly-ranking") + @ResponseStatus(HttpStatus.OK) + @Override + public ApiResponse runMonthlyRankingJob( + @RequestParam String targetDate + ) { + validateTargetDateFormat(targetDate); + JobExecution execution = launchJob(monthlyRankingJob, targetDate); + return ApiResponse.success(BatchAdminV1Dto.JobExecutionResponse.from(execution)); + } + + private void validateTargetDateFormat(String targetDate) { + try { + LocalDate.parse(targetDate, RankingJobConstants.DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new CoreException( + ErrorType.BATCH_INVALID_DATE_FORMAT, + "잘못된 날짜 형식입니다. 예상: yyyyMMdd, 입력값: " + targetDate + ); + } + } + + private JobExecution launchJob(Job job, String targetDate) { + try { + JobParameters params = new JobParametersBuilder() + .addString("targetDate", targetDate) + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + log.info("Launching job: {}, targetDate: {}", job.getName(), targetDate); + JobExecution execution = jobLauncher.run(job, params); + log.info("Job completed: {}, status: {}", job.getName(), execution.getStatus()); + + return execution; + } catch (Exception e) { + log.error("Failed to launch job: {}", job.getName(), e); + throw new CoreException( + ErrorType.BATCH_JOB_FAILED, + "배치 Job 실행 실패: " + e.getMessage() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Dto.java new file mode 100644 index 0000000000..02eb30aa44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchAdminV1Dto.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.batch; + +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; + +import java.time.LocalDateTime; + +/** + * Batch Admin API DTO. + */ +public final class BatchAdminV1Dto { + + private BatchAdminV1Dto() {} + + /** + * Job 실행 결과 응답. + */ + public record JobExecutionResponse( + Long executionId, + String jobName, + BatchStatus status, + LocalDateTime startTime, + LocalDateTime endTime, + String exitCode, + String exitDescription + ) { + public static JobExecutionResponse from(JobExecution execution) { + return new JobExecutionResponse( + execution.getId(), + execution.getJobInstance().getJobName(), + execution.getStatus(), + execution.getStartTime(), + execution.getEndTime(), + execution.getExitStatus().getExitCode(), + execution.getExitStatus().getExitDescription() + ); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 33c8226624..37cc67603e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -54,7 +54,11 @@ public enum ErrorType { /** 결제 관련 에러 */ PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_NOT_FOUND", "결제 정보를 찾을 수 없습니다."), PAYMENT_ALREADY_EXISTS(HttpStatus.CONFLICT, "PAYMENT_ALREADY_EXISTS", "이미 결제가 존재합니다."), - PAYMENT_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_REQUEST_FAILED", "결제 요청에 실패했습니다."); + PAYMENT_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "PAYMENT_REQUEST_FAILED", "결제 요청에 실패했습니다."), + + /** 배치 관련 에러 */ + BATCH_INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "BATCH_INVALID_DATE_FORMAT", "잘못된 날짜 형식입니다. (yyyyMMdd)"), + BATCH_JOB_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BATCH_JOB_FAILED", "배치 Job 실행에 실패했습니다."); private final HttpStatus status; private final String code; diff --git a/http/batch-admin-api.http b/http/batch-admin-api.http new file mode 100644 index 0000000000..6e19821e24 --- /dev/null +++ b/http/batch-admin-api.http @@ -0,0 +1,16 @@ +### Batch Admin API +### 배치 Job 실행 관리 API + +### 주간 랭킹 집계 Job 실행 +### targetDate가 속한 주(월~일)의 product_metrics_daily 데이터를 집계하여 TOP 100 랭킹 생성 +POST http://localhost:8080/api-admin/v1/batch/weekly-ranking?targetDate=20250414 +Content-Type: application/json + +### 월간 랭킹 집계 Job 실행 +### targetDate가 속한 월(1일~말일)의 product_metrics_daily 데이터를 집계하여 TOP 100 랭킹 생성 +POST http://localhost:8080/api-admin/v1/batch/monthly-ranking?targetDate=20250401 +Content-Type: application/json + +### 잘못된 날짜 형식 테스트 (400 에러 예상) +POST http://localhost:8080/api-admin/v1/batch/weekly-ranking?targetDate=2025-04-14 +Content-Type: application/json \ No newline at end of file From cd4cb030208631ad1921daca59ba4f2182a5ca33 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:19:59 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(ranking):=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EB=B3=84=20=EB=9E=AD=ED=82=B9=20API=20=ED=99=95=EC=9E=A5=20(DA?= =?UTF-8?q?ILY/WEEKLY/MONTHLY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RankingPeriod enum: DAILY, WEEKLY, MONTHLY - PeriodRankingResult: 기간별 랭킹 결과 DTO - RankingQueryService: getPeriodRankings() 메서드 추가 - DAILY: Redis ZSET 조회 (기존 로직) - WEEKLY: mv_product_rank_weekly 조회 - MONTHLY: mv_product_rank_monthly 조회 - RankingV1Controller: period 파라미터 추가 (기본값: DAILY) - 응답에 viewCount, likeCount, orderCount, periodStart, periodEnd 추가 - HTTP 요청 예시 업데이트 Co-Authored-By: Claude Opus 4.5 --- .../ranking/PeriodRankingResult.java | 85 ++++++++ .../application/ranking/RankingPeriod.java | 26 +++ .../ranking/RankingQueryService.java | 190 ++++++++++++++++++ .../api/ranking/RankingV1ApiSpec.java | 17 +- .../api/ranking/RankingV1Controller.java | 41 +++- .../interfaces/api/ranking/RankingV1Dto.java | 73 +++++++ http/ranking-api.http | 16 +- 7 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/PeriodRankingResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/PeriodRankingResult.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/PeriodRankingResult.java new file mode 100644 index 0000000000..bc67c46159 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/PeriodRankingResult.java @@ -0,0 +1,85 @@ +package com.loopers.application.ranking; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 기간별 랭킹 조회 결과. + * 일간/주간/월간 랭킹을 통합하여 표현합니다. + */ +public record PeriodRankingResult( + int rank, + Long productId, + String productName, + Long productPrice, + String productImageUrl, + BigDecimal score, + Long viewCount, + Long likeCount, + Long orderCount, + RankingPeriod period, + LocalDate periodStart, + LocalDate periodEnd +) { + /** + * 일간 랭킹용 팩토리 메서드 (Redis 기반). + * viewCount, likeCount, orderCount는 일간 랭킹에서는 별도 집계하지 않으므로 null로 설정합니다. + */ + public static PeriodRankingResult ofDaily( + int rank, + Long productId, + String productName, + Long productPrice, + String productImageUrl, + Double score, + LocalDate date + ) { + return new PeriodRankingResult( + rank, + productId, + productName, + productPrice, + productImageUrl, + score != null ? BigDecimal.valueOf(score) : null, + null, // viewCount + null, // likeCount + null, // orderCount + RankingPeriod.DAILY, + date, + date + ); + } + + /** + * 주간/월간 랭킹용 팩토리 메서드 (DB 배치 집계 기반). + */ + public static PeriodRankingResult ofPeriod( + int rank, + Long productId, + String productName, + Long productPrice, + String productImageUrl, + BigDecimal score, + Long viewCount, + Long likeCount, + Long orderCount, + RankingPeriod period, + LocalDate periodStart, + LocalDate periodEnd + ) { + return new PeriodRankingResult( + rank, + productId, + productName, + productPrice, + productImageUrl, + score, + viewCount, + likeCount, + orderCount, + period, + periodStart, + periodEnd + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java new file mode 100644 index 0000000000..cccf7003cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java @@ -0,0 +1,26 @@ +package com.loopers.application.ranking; + +/** + * 랭킹 조회 기간 타입. + * + *

설계 결정: + * 현재 3가지 분기(DAILY, WEEKLY, MONTHLY)이므로 단순 switch 분기로 충분합니다. + * 추후 기간 타입이 5개 이상으로 늘어나거나, 각 타입별 복잡한 비즈니스 로직이 필요해지면 + * Strategy 패턴으로 리팩토링을 고려합니다. + */ +public enum RankingPeriod { + /** + * 일간 랭킹 (Redis ZSET 기반, 실시간성 중요) + */ + DAILY, + + /** + * 주간 랭킹 (mv_product_rank_weekly 테이블, 배치 집계) + */ + WEEKLY, + + /** + * 월간 랭킹 (mv_product_rank_monthly 테이블, 배치 집계) + */ + MONTHLY +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java index f7a92093c3..0de092224a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java @@ -2,6 +2,10 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -9,9 +13,12 @@ import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.stereotype.Service; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -32,6 +39,8 @@ public class RankingQueryService { private final RedisTemplate redisTemplate; private final ProductRepository productRepository; + private final ProductRankWeeklyJpaRepository weeklyRankRepository; + private final ProductRankMonthlyJpaRepository monthlyRankRepository; private final RankingProperties properties; /** @@ -197,4 +206,185 @@ public long getHourlyTotalCount(LocalDateTime dateTime) { private String generateHourlyKey(LocalDateTime dateTime) { return HOURLY_KEY_PREFIX + dateTime.format(HOURLY_FORMATTER); } + + // ========== 기간별 랭킹 조회 (일간/주간/월간 통합) ========== + + /** + * 기간별 랭킹을 조회합니다. + * + * @param date 조회 기준 날짜 + * @param period 조회 기간 (DAILY, WEEKLY, MONTHLY) + * @param size 페이지 크기 + * @param offset 시작 위치 (0-based) + * @return 기간별 랭킹 결과 리스트 + */ + public List getPeriodRankings(LocalDate date, RankingPeriod period, int size, int offset) { + return switch (period) { + case DAILY -> getDailyPeriodRankings(date, size, offset); + case WEEKLY -> getWeeklyPeriodRankings(date, size, offset); + case MONTHLY -> getMonthlyPeriodRankings(date, size, offset); + }; + } + + /** + * 기간별 랭킹의 전체 개수를 조회합니다. + * + * @param date 조회 기준 날짜 + * @param period 조회 기간 (DAILY, WEEKLY, MONTHLY) + * @return 전체 개수 + */ + public long getPeriodTotalCount(LocalDate date, RankingPeriod period) { + return switch (period) { + case DAILY -> getTotalCount(date); + case WEEKLY -> getWeeklyTotalCount(date); + case MONTHLY -> getMonthlyTotalCount(date); + }; + } + + private List getDailyPeriodRankings(LocalDate date, int size, int offset) { + String key = generateKey(date); + + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, offset, offset + size - 1); + + if (tuples == null || tuples.isEmpty()) { + return List.of(); + } + + List productIds = tuples.stream() + .map(TypedTuple::getValue) + .filter(Objects::nonNull) + .map(Long::parseLong) + .toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + List results = new ArrayList<>(); + int rank = offset + 1; + + for (TypedTuple tuple : tuples) { + Long productId = Long.parseLong(Objects.requireNonNull(tuple.getValue())); + Product product = productMap.get(productId); + + if (product != null) { + results.add(PeriodRankingResult.ofDaily( + rank, + productId, + product.getName(), + product.getPrice().amount(), + product.getImageUrl(), + tuple.getScore(), + date + )); + } + rank++; + } + + return results; + } + + private List getWeeklyPeriodRankings(LocalDate date, int size, int offset) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + List entities = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(weekStart); + + if (entities.isEmpty()) { + return List.of(); + } + + // 페이징 적용 + int endIndex = Math.min(offset + size, entities.size()); + if (offset >= entities.size()) { + return List.of(); + } + List pagedEntities = entities.subList(offset, endIndex); + + // 상품 정보 조회 + List productIds = pagedEntities.stream() + .map(ProductRankWeeklyJpaEntity::getProductId) + .toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + return pagedEntities.stream() + .map(entity -> { + Product product = productMap.get(entity.getProductId()); + return PeriodRankingResult.ofPeriod( + entity.getRankNumber(), + entity.getProductId(), + product != null ? product.getName() : null, + product != null ? product.getPrice().amount() : null, + product != null ? product.getImageUrl() : null, + entity.getTotalScore(), + entity.getTotalViewCount(), + entity.getTotalLikeCount(), + entity.getTotalOrderCount(), + RankingPeriod.WEEKLY, + entity.getPeriodStartDate(), + entity.getPeriodEndDate() + ); + }) + .filter(r -> r.productName() != null) // 삭제된 상품 필터링 + .toList(); + } + + private List getMonthlyPeriodRankings(LocalDate date, int size, int offset) { + LocalDate monthStart = YearMonth.from(date).atDay(1); + + List entities = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(monthStart); + + if (entities.isEmpty()) { + return List.of(); + } + + // 페이징 적용 + int endIndex = Math.min(offset + size, entities.size()); + if (offset >= entities.size()) { + return List.of(); + } + List pagedEntities = entities.subList(offset, endIndex); + + // 상품 정보 조회 + List productIds = pagedEntities.stream() + .map(ProductRankMonthlyJpaEntity::getProductId) + .toList(); + + Map productMap = productRepository.findAllByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + return pagedEntities.stream() + .map(entity -> { + Product product = productMap.get(entity.getProductId()); + return PeriodRankingResult.ofPeriod( + entity.getRankNumber(), + entity.getProductId(), + product != null ? product.getName() : null, + product != null ? product.getPrice().amount() : null, + product != null ? product.getImageUrl() : null, + entity.getTotalScore(), + entity.getTotalViewCount(), + entity.getTotalLikeCount(), + entity.getTotalOrderCount(), + RankingPeriod.MONTHLY, + entity.getPeriodStartDate(), + entity.getPeriodEndDate() + ); + }) + .filter(r -> r.productName() != null) // 삭제된 상품 필터링 + .toList(); + } + + private long getWeeklyTotalCount(LocalDate date) { + LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + return weeklyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(weekStart).size(); + } + + private long getMonthlyTotalCount(LocalDate date) { + LocalDate monthStart = YearMonth.from(date).atDay(1); + return monthlyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(monthStart).size(); + } } 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 783c54710c..1c68070023 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 @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,12 +10,22 @@ public interface RankingV1ApiSpec { @Operation( - summary = "랭킹 조회", - description = "일별 상품 랭킹을 조회합니다. date 파라미터가 없으면 오늘 날짜를 기본값으로 사용합니다." + summary = "랭킹 조회 (기간별)", + description = """ + 기간별 상품 랭킹을 조회합니다. + - period=DAILY: 일간 랭킹 (Redis 실시간 데이터) + - period=WEEKLY: 주간 랭킹 (date가 속한 주의 배치 집계 데이터) + - period=MONTHLY: 월간 랭킹 (date가 속한 월의 배치 집계 데이터) + + date 파라미터가 없으면 오늘 날짜를 기본값으로 사용합니다. + period 파라미터가 없으면 DAILY를 기본값으로 사용합니다. + """ ) - ApiResponse getRankings( + ApiResponse getRankings( @Parameter(description = "조회 날짜 (yyyyMMdd 형식)", example = "20250407") String date, + @Parameter(description = "조회 기간 (DAILY, WEEKLY, MONTHLY)", example = "DAILY") + RankingPeriod period, @Parameter(description = "페이지 크기", example = "20") int size, @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") 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 98a45d3dc6..3d54dde055 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.PeriodRankingResult; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.application.ranking.RankingQueryService; import com.loopers.application.ranking.RankingResult; import com.loopers.interfaces.api.ApiResponse; @@ -9,9 +11,12 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; import java.util.List; @RequiredArgsConstructor @@ -26,8 +31,9 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override - public ApiResponse getRankings( + public ApiResponse getRankings( @RequestParam(required = false) String date, + @RequestParam(defaultValue = "DAILY") RankingPeriod period, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { @@ -36,22 +42,45 @@ public ApiResponse getRankings( : LocalDate.now(); int offset = (page - 1) * size; - List results = rankingQueryService.getRankings(queryDate, size, offset); - long totalCount = rankingQueryService.getTotalCount(queryDate); + List results = rankingQueryService.getPeriodRankings(queryDate, period, size, offset); + long totalCount = rankingQueryService.getPeriodTotalCount(queryDate, period); - List rankings = results.stream() - .map(RankingV1Dto.RankingResponse::from) + List rankings = results.stream() + .map(RankingV1Dto.PeriodRankingResponse::from) .toList(); - return ApiResponse.success(RankingV1Dto.RankingPageResponse.of( + // 기간 시작/종료일 계산 + LocalDate periodStart = calculatePeriodStart(queryDate, period); + LocalDate periodEnd = calculatePeriodEnd(queryDate, period); + + return ApiResponse.success(RankingV1Dto.PeriodRankingPageResponse.of( rankings, queryDate.format(DATE_FORMATTER), + period, + periodStart.format(DATE_FORMATTER), + periodEnd.format(DATE_FORMATTER), page, size, totalCount )); } + private LocalDate calculatePeriodStart(LocalDate date, RankingPeriod period) { + return switch (period) { + case DAILY -> date; + case WEEKLY -> date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + case MONTHLY -> YearMonth.from(date).atDay(1); + }; + } + + private LocalDate calculatePeriodEnd(LocalDate date, RankingPeriod period) { + return switch (period) { + case DAILY -> date; + case WEEKLY -> date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + case MONTHLY -> YearMonth.from(date).atEndOfMonth(); + }; + } + @GetMapping("/hourly") @Override public ApiResponse getHourlyRankings( 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 221ba4a0a5..88449adec4 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 @@ -1,8 +1,11 @@ package com.loopers.interfaces.api.ranking; +import com.loopers.application.ranking.PeriodRankingResult; import com.loopers.application.ranking.RankInfo; +import com.loopers.application.ranking.RankingPeriod; import com.loopers.application.ranking.RankingResult; +import java.math.BigDecimal; import java.util.List; public class RankingV1Dto { @@ -66,4 +69,74 @@ public static HourlyRankingPageResponse of(List rankings, Strin return new HourlyRankingPageResponse(rankings, hour, page, size, totalCount, totalPages); } } + + /** + * 기간별 랭킹 단일 항목 응답. + * 일간/주간/월간 랭킹을 통합 표현합니다. + */ + public record PeriodRankingResponse( + int rank, + Long productId, + String productName, + Long productPrice, + String productImageUrl, + BigDecimal score, + Long viewCount, + Long likeCount, + Long orderCount, + RankingPeriod period, + String periodStart, + String periodEnd + ) { + private static final java.time.format.DateTimeFormatter DATE_FORMATTER = + java.time.format.DateTimeFormatter.BASIC_ISO_DATE; + + public static PeriodRankingResponse from(PeriodRankingResult result) { + return new PeriodRankingResponse( + result.rank(), + result.productId(), + result.productName(), + result.productPrice(), + result.productImageUrl(), + result.score(), + result.viewCount(), + result.likeCount(), + result.orderCount(), + result.period(), + result.periodStart() != null ? result.periodStart().format(DATE_FORMATTER) : null, + result.periodEnd() != null ? result.periodEnd().format(DATE_FORMATTER) : null + ); + } + } + + /** + * 기간별 랭킹 페이지 응답. + */ + public record PeriodRankingPageResponse( + List rankings, + String date, + RankingPeriod period, + String periodStart, + String periodEnd, + int page, + int size, + long totalCount, + int totalPages + ) { + public static PeriodRankingPageResponse of( + List rankings, + String date, + RankingPeriod period, + String periodStart, + String periodEnd, + int page, + int size, + long totalCount + ) { + int totalPages = (int) Math.ceil((double) totalCount / size); + return new PeriodRankingPageResponse( + rankings, date, period, periodStart, periodEnd, page, size, totalCount, totalPages + ); + } + } } diff --git a/http/ranking-api.http b/http/ranking-api.http index 8914a5d725..b0244eb721 100644 --- a/http/ranking-api.http +++ b/http/ranking-api.http @@ -1,13 +1,19 @@ -### Ranking API - 랭킹 관련 API +### Ranking API - 랭킹 관련 API (기간별: DAILY/WEEKLY/MONTHLY) -### 오늘 랭킹 조회 (기본값) +### 오늘 일간 랭킹 조회 (기본값: period=DAILY) GET {{baseUrl}}/api/v1/rankings?size=20&page=1 -### 특정 날짜 랭킹 조회 -GET {{baseUrl}}/api/v1/rankings?date=20250407&size=20&page=1 +### 특정 날짜 일간 랭킹 조회 +GET {{baseUrl}}/api/v1/rankings?date=20250407&period=DAILY&size=20&page=1 + +### 주간 랭킹 조회 (date가 속한 주의 월~일 데이터) +GET {{baseUrl}}/api/v1/rankings?date=20250407&period=WEEKLY&size=20&page=1 + +### 월간 랭킹 조회 (date가 속한 월의 1일~말일 데이터) +GET {{baseUrl}}/api/v1/rankings?date=20250407&period=MONTHLY&size=20&page=1 ### 랭킹 2페이지 조회 -GET {{baseUrl}}/api/v1/rankings?size=10&page=2 +GET {{baseUrl}}/api/v1/rankings?date=20250407&period=WEEKLY&size=10&page=2 ### From 152236eae5a36ee26189c3a466c954b037a056be Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:20:09 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test(ranking):=20=EB=B0=B0=EC=B9=98=20Job?= =?UTF-8?q?=20=EB=B0=8F=20=EB=9E=AD=ED=82=B9=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 배치 Job 테스트: - WeeklyRankingJobTest: 주간 집계, 날짜 경계, 멱등성 검증 - MonthlyRankingJobTest: 월간 집계, 윤년/평년/30일 월 처리 검증 - RankingJobConstantsTest: 상수 및 날짜 계산 유틸 단위 테스트 - BatchAdminV1ControllerTest: Admin API 통합 테스트 랭킹 API 테스트: - RankingV1PeriodApiTest: 기간별 랭킹 조회 테스트 통합 테스트: - RankingPipelineIntegrationTest: 전체 파이프라인 E2E 검증 - 테스트 데이터 생성 → 배치 실행 → API 조회 → 멱등성 확인 테스트 데이터: - ranking_test_data.sql: 200상품 x 30일 테스트 데이터 스크립트 Co-Authored-By: Claude Opus 4.5 --- .../com/loopers/batch/BatchMetaTableTest.java | 45 ++ .../batch/RankingPipelineIntegrationTest.java | 424 ++++++++++++++++++ .../job/common/RankingJobConstantsTest.java | 229 ++++++++++ .../monthlyranking/MonthlyRankingJobTest.java | 336 ++++++++++++++ .../weeklyranking/WeeklyRankingJobTest.java | 253 +++++++++++ .../api/batch/BatchAdminV1ControllerTest.java | 126 ++++++ .../api/ranking/RankingV1PeriodApiTest.java | 297 ++++++++++++ scripts/test-data/ranking_test_data.sql | 113 +++++ 8 files changed, 1823 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/batch/BatchMetaTableTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/batch/RankingPipelineIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/batch/job/common/RankingJobConstantsTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/batch/BatchAdminV1ControllerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1PeriodApiTest.java create mode 100644 scripts/test-data/ranking_test_data.sql diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/BatchMetaTableTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/BatchMetaTableTest.java new file mode 100644 index 0000000000..ca4caeddac --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/batch/BatchMetaTableTest.java @@ -0,0 +1,45 @@ +package com.loopers.batch; + +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.jdbc.core.JdbcTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("Spring Batch 메타 테이블 생성 테스트") +class BatchMetaTableTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("Spring Batch 메타 테이블이 정상 생성된다") + void batchMetaTablesCreated() { + // Arrange + List expectedTables = List.of( + "BATCH_JOB_INSTANCE", + "BATCH_JOB_EXECUTION", + "BATCH_JOB_EXECUTION_PARAMS", + "BATCH_STEP_EXECUTION", + "BATCH_STEP_EXECUTION_CONTEXT", + "BATCH_JOB_EXECUTION_CONTEXT" + ); + + // Act & Assert + for (String tableName : expectedTables) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?", + Integer.class, + tableName + ); + assertThat(count) + .as("Table %s should exist", tableName) + .isEqualTo(1); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/RankingPipelineIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/RankingPipelineIntegrationTest.java new file mode 100644 index 0000000000..80709e655b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/batch/RankingPipelineIntegrationTest.java @@ -0,0 +1,424 @@ +package com.loopers.batch; + +import com.loopers.application.ranking.RankingPeriod; +import com.loopers.config.TestRedisConfiguration; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaRepository; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.batch.BatchAdminV1Dto; +import com.loopers.interfaces.api.ranking.RankingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 랭킹 파이프라인 전체 통합 테스트. + * + *

테스트 시나리오: + *

    + *
  1. 테스트 데이터 준비 (product_metrics_daily)
  2. + *
  3. 주간 배치 Job 실행 및 검증
  4. + *
  5. 월간 배치 Job 실행 및 검증
  6. + *
  7. 기간별 랭킹 API 조회 검증
  8. + *
  9. 배치 재실행 시 멱등성 검증
  10. + *
+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestRedisConfiguration.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("랭킹 파이프라인 통합 테스트") +class RankingPipelineIntegrationTest { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final String TARGET_DATE = "20250414"; // 2025년 4월 14일 (월요일) + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private ProductRankWeeklyJpaRepository weeklyRankRepository; + + @Autowired + private ProductRankMonthlyJpaRepository monthlyRankRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Brand testBrand; + private List testProducts; + + @BeforeEach + void setUp() { + cleanUp(); + setupTestData(); + } + + @AfterEach + void tearDown() { + cleanUp(); + databaseCleanUp.truncateAllTables(); + } + + private void cleanUp() { + Set keys = redisTemplate.keys("ranking:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + /** + * 테스트 데이터 준비: + * - 상품 10개 생성 + * - 2025년 4월 1일~30일 일별 메트릭 데이터 + * - Product 1이 최고점, Product 2가 2위, Product 3이 3위가 되도록 가중치 부여 + */ + private void setupTestData() { + testBrand = brandRepository.save(Brand.create("Test Brand", "Test", null)); + + // 10개 상품 생성 + testProducts = new java.util.ArrayList<>(); + for (int i = 1; i <= 10; i++) { + Product product = productRepository.save( + Product.create(testBrand.getId(), "Product " + i, "Desc", new Money(10000L * i), new Stock(100), null) + ); + testProducts.add(product); + } + + // 2025년 4월 1일~30일 일별 메트릭 데이터 삽입 + LocalDate startDate = LocalDate.of(2025, 4, 1); + LocalDate endDate = LocalDate.of(2025, 4, 30); + + for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { + // Product 1: 최고 점수 (일평균 25~30) + insertDailyMetrics(testProducts.get(0).getId(), date, 120, 60, 25, BigDecimal.valueOf(27.5)); + + // Product 2: 2위 (일평균 20~24) + insertDailyMetrics(testProducts.get(1).getId(), date, 100, 50, 20, BigDecimal.valueOf(22.0)); + + // Product 3: 3위 (일평균 15~18) + insertDailyMetrics(testProducts.get(2).getId(), date, 80, 40, 15, BigDecimal.valueOf(16.5)); + + // Products 4~10: 낮은 점수 (일평균 5~12) + for (int i = 3; i < testProducts.size(); i++) { + int baseScore = 12 - i; // 4번째 상품: 8, 5번째: 7, ... + insertDailyMetrics( + testProducts.get(i).getId(), + date, + 20 + i * 5, + 10 + i * 2, + 2 + i, + BigDecimal.valueOf(baseScore) + ); + } + } + } + + private void insertDailyMetrics(Long productId, LocalDate date, int viewCount, int likeCount, int orderCount, BigDecimal score) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + productId, date, viewCount, likeCount, orderCount, score + ); + } + + @Test + @Order(1) + @DisplayName("1. 테스트 데이터가 정상 생성되었는지 확인") + void verifyTestDataCreated() { + // 상품 수 확인 + assertThat(testProducts).hasSize(10); + + // 일별 메트릭 데이터 수 확인 (10 상품 x 30 일 = 300) + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM product_metrics_daily WHERE metric_date BETWEEN '2025-04-01' AND '2025-04-30'", + Integer.class + ); + assertThat(count).isEqualTo(300); + + // 월간 총점 상위 3개 확인 + List topProductIds = jdbcTemplate.query( + """ + SELECT product_id FROM product_metrics_daily + WHERE metric_date BETWEEN '2025-04-01' AND '2025-04-30' + GROUP BY product_id + ORDER BY SUM(score) DESC + LIMIT 3 + """, + (rs, rowNum) -> rs.getLong("product_id") + ); + + assertThat(topProductIds).containsExactly( + testProducts.get(0).getId(), + testProducts.get(1).getId(), + testProducts.get(2).getId() + ); + } + + @Test + @Order(2) + @DisplayName("2. 주간 배치 Job 실행 및 결과 검증") + void runWeeklyBatchJobAndVerify() { + // Act - 주간 배치 실행 + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/batch/weekly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert - Job 실행 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().status().name()).isEqualTo("COMPLETED"); + assertThat(response.getBody().data().jobName()).isEqualTo("weeklyRankingJob"); + + // Assert - 주간 랭킹 테이블에 데이터 생성됨 + // 2025-04-14는 4월 14일(월) ~ 4월 20일(일) 주 + LocalDate weekStart = LocalDate.of(2025, 4, 14); + var weeklyRankings = weeklyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(weekStart); + + assertThat(weeklyRankings).isNotEmpty(); + assertThat(weeklyRankings.size()).isLessThanOrEqualTo(100); // TOP 100 + + // 1위: Product 1 + assertThat(weeklyRankings.get(0).getProductId()).isEqualTo(testProducts.get(0).getId()); + assertThat(weeklyRankings.get(0).getRankNumber()).isEqualTo(1); + + // 2위: Product 2 + assertThat(weeklyRankings.get(1).getProductId()).isEqualTo(testProducts.get(1).getId()); + assertThat(weeklyRankings.get(1).getRankNumber()).isEqualTo(2); + + // 3위: Product 3 + assertThat(weeklyRankings.get(2).getProductId()).isEqualTo(testProducts.get(2).getId()); + assertThat(weeklyRankings.get(2).getRankNumber()).isEqualTo(3); + } + + @Test + @Order(3) + @DisplayName("3. 월간 배치 Job 실행 및 결과 검증") + void runMonthlyBatchJobAndVerify() { + // Act - 월간 배치 실행 + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/batch/monthly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert - Job 실행 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().status().name()).isEqualTo("COMPLETED"); + assertThat(response.getBody().data().jobName()).isEqualTo("monthlyRankingJob"); + + // Assert - 월간 랭킹 테이블에 데이터 생성됨 + LocalDate monthStart = LocalDate.of(2025, 4, 1); + var monthlyRankings = monthlyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(monthStart); + + assertThat(monthlyRankings).hasSize(10); // 10개 상품 모두 + + // 1위: Product 1 (30일 * 27.5 = 825) + assertThat(monthlyRankings.get(0).getProductId()).isEqualTo(testProducts.get(0).getId()); + assertThat(monthlyRankings.get(0).getRankNumber()).isEqualTo(1); + assertThat(monthlyRankings.get(0).getTotalViewCount()).isEqualTo(120 * 30); // 3600 + assertThat(monthlyRankings.get(0).getTotalLikeCount()).isEqualTo(60 * 30); // 1800 + assertThat(monthlyRankings.get(0).getTotalOrderCount()).isEqualTo(25 * 30); // 750 + + // 2위: Product 2 + assertThat(monthlyRankings.get(1).getProductId()).isEqualTo(testProducts.get(1).getId()); + + // 3위: Product 3 + assertThat(monthlyRankings.get(2).getProductId()).isEqualTo(testProducts.get(2).getId()); + } + + @Test + @Order(4) + @DisplayName("4. 주간 랭킹 API 조회 검증") + void getWeeklyRankingsApi() { + // Arrange - 먼저 배치 실행 + testRestTemplate.exchange( + "/api-admin/v1/batch/weekly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference>() {} + ); + + // Act - 주간 랭킹 API 조회 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=" + TARGET_DATE + "&period=WEEKLY&size=10&page=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + + var data = response.getBody().data(); + assertThat(data.period().name()).isEqualTo("WEEKLY"); + assertThat(data.periodStart()).isEqualTo("20250414"); // 월요일 + assertThat(data.periodEnd()).isEqualTo("20250420"); // 일요일 + assertThat(data.rankings()).isNotEmpty(); + + // 1위 검증 + var firstRanking = data.rankings().get(0); + assertThat(firstRanking.rank()).isEqualTo(1); + assertThat(firstRanking.productId()).isEqualTo(testProducts.get(0).getId()); + assertThat(firstRanking.productName()).isEqualTo("Product 1"); + assertThat(firstRanking.viewCount()).isNotNull(); + assertThat(firstRanking.likeCount()).isNotNull(); + assertThat(firstRanking.orderCount()).isNotNull(); + } + + @Test + @Order(5) + @DisplayName("5. 월간 랭킹 API 조회 검증") + void getMonthlyRankingsApi() { + // Arrange - 먼저 배치 실행 + testRestTemplate.exchange( + "/api-admin/v1/batch/monthly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference>() {} + ); + + // Act - 월간 랭킹 API 조회 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=" + TARGET_DATE + "&period=MONTHLY&size=10&page=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + + var data = response.getBody().data(); + assertThat(data.period().name()).isEqualTo("MONTHLY"); + assertThat(data.periodStart()).isEqualTo("20250401"); // 4월 1일 + assertThat(data.periodEnd()).isEqualTo("20250430"); // 4월 30일 + assertThat(data.rankings()).hasSize(10); + assertThat(data.totalCount()).isEqualTo(10); + + // 상위 3위 순서 검증 + assertThat(data.rankings().get(0).productId()).isEqualTo(testProducts.get(0).getId()); + assertThat(data.rankings().get(1).productId()).isEqualTo(testProducts.get(1).getId()); + assertThat(data.rankings().get(2).productId()).isEqualTo(testProducts.get(2).getId()); + } + + @Test + @Order(6) + @DisplayName("6. 배치 재실행 시 멱등성 검증 (데이터 중복 없음)") + void batchIdempotencyTest() { + // Arrange - 첫 번째 실행 + testRestTemplate.exchange( + "/api-admin/v1/batch/weekly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference>() {} + ); + + LocalDate weekStart = LocalDate.of(2025, 4, 14); + int countAfterFirstRun = weeklyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(weekStart).size(); + + // Act - 두 번째 실행 (재실행) + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/batch/weekly-ranking?targetDate=" + TARGET_DATE, + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert - Job 성공 + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().status().name()).isEqualTo("COMPLETED"); + + // Assert - 데이터 중복 없음 (동일 개수) + int countAfterSecondRun = weeklyRankRepository.findByPeriodStartDateOrderByRankNumberAsc(weekStart).size(); + assertThat(countAfterSecondRun).isEqualTo(countAfterFirstRun); + } + + @Test + @Order(7) + @DisplayName("7. 일간 랭킹 API 조회 (Redis 기반)") + void getDailyRankingsApi() { + // Arrange - Redis에 일간 데이터 삽입 + String today = LocalDate.now().format(DATE_FORMATTER); + String key = "ranking:all:" + today; + + redisTemplate.opsForZSet().add(key, String.valueOf(testProducts.get(0).getId()), 100.0); + redisTemplate.opsForZSet().add(key, String.valueOf(testProducts.get(1).getId()), 80.0); + redisTemplate.opsForZSet().add(key, String.valueOf(testProducts.get(2).getId()), 60.0); + + // Act - 일간 랭킹 API 조회 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=" + today + "&period=DAILY&size=10&page=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + + var data = response.getBody().data(); + assertThat(data.period().name()).isEqualTo("DAILY"); + assertThat(data.periodStart()).isEqualTo(today); + assertThat(data.periodEnd()).isEqualTo(today); + assertThat(data.rankings()).hasSize(3); + + // 일간 랭킹은 viewCount/likeCount/orderCount가 null + assertThat(data.rankings().get(0).viewCount()).isNull(); + assertThat(data.rankings().get(0).likeCount()).isNull(); + assertThat(data.rankings().get(0).orderCount()).isNull(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/job/common/RankingJobConstantsTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/job/common/RankingJobConstantsTest.java new file mode 100644 index 0000000000..b0cc7da2e7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/batch/job/common/RankingJobConstantsTest.java @@ -0,0 +1,229 @@ +package com.loopers.batch.job.common; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 랭킹 Job 공통 상수 및 유틸리티 테스트. + */ +@DisplayName("RankingJobConstants 테스트") +class RankingJobConstantsTest { + + @Nested + @DisplayName("DATE_FORMATTER") + class DateFormatterTest { + + @Test + @DisplayName("yyyyMMdd 형식으로 날짜를 파싱한다") + void parseDateFormat() { + // Arrange + String dateStr = "20250414"; + + // Act + LocalDate date = LocalDate.parse(dateStr, RankingJobConstants.DATE_FORMATTER); + + // Assert + assertThat(date.getYear()).isEqualTo(2025); + assertThat(date.getMonthValue()).isEqualTo(4); + assertThat(date.getDayOfMonth()).isEqualTo(14); + } + + @Test + @DisplayName("yyyyMMdd 형식으로 날짜를 포맷한다") + void formatDate() { + // Arrange + LocalDate date = LocalDate.of(2025, 4, 14); + + // Act + String formatted = date.format(RankingJobConstants.DATE_FORMATTER); + + // Assert + assertThat(formatted).isEqualTo("20250414"); + } + } + + @Nested + @DisplayName("buildAggregationSql") + class BuildAggregationSqlTest { + + @Test + @DisplayName("시작일과 종료일이 SQL에 포함된다") + void containsDateRange() { + // Act + String sql = RankingJobConstants.buildAggregationSql("2025-04-14", "2025-04-20"); + + // Assert + assertThat(sql).contains("'2025-04-14'"); + assertThat(sql).contains("'2025-04-20'"); + } + + @Test + @DisplayName("TOP_N 값이 LIMIT에 포함된다") + void containsLimit() { + // Act + String sql = RankingJobConstants.buildAggregationSql("2025-04-01", "2025-04-30"); + + // Assert + assertThat(sql).contains("LIMIT " + RankingJobConstants.TOP_N); + } + + @Test + @DisplayName("GROUP BY와 ORDER BY가 포함된다") + void containsGroupByAndOrderBy() { + // Act + String sql = RankingJobConstants.buildAggregationSql("2025-04-01", "2025-04-30"); + + // Assert + assertThat(sql).contains("GROUP BY product_id"); + assertThat(sql).contains("ORDER BY total_score DESC"); + } + } + + @Nested + @DisplayName("주간 날짜 범위 계산") + class WeeklyDateRangeTest { + + @Test + @DisplayName("월요일이 주의 시작일이다") + void weekStartIsMonday() { + // Arrange + LocalDate wednesday = LocalDate.of(2025, 4, 16); // 수요일 + + // Act + LocalDate weekStart = wednesday.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + // Assert + assertThat(weekStart).isEqualTo(LocalDate.of(2025, 4, 14)); // 월요일 + assertThat(weekStart.getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + } + + @Test + @DisplayName("일요일이 주의 종료일이다") + void weekEndIsSunday() { + // Arrange + LocalDate wednesday = LocalDate.of(2025, 4, 16); // 수요일 + + // Act + LocalDate weekEnd = wednesday.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + + // Assert + assertThat(weekEnd).isEqualTo(LocalDate.of(2025, 4, 20)); // 일요일 + assertThat(weekEnd.getDayOfWeek()).isEqualTo(DayOfWeek.SUNDAY); + } + + @Test + @DisplayName("월요일 입력 시 해당 월요일이 시작일이다") + void mondayInputReturnsItself() { + // Arrange + LocalDate monday = LocalDate.of(2025, 4, 14); // 월요일 + + // Act + LocalDate weekStart = monday.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + + // Assert + assertThat(weekStart).isEqualTo(monday); + } + + @Test + @DisplayName("일요일 입력 시 해당 일요일이 종료일이다") + void sundayInputReturnsItself() { + // Arrange + LocalDate sunday = LocalDate.of(2025, 4, 20); // 일요일 + + // Act + LocalDate weekEnd = sunday.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + + // Assert + assertThat(weekEnd).isEqualTo(sunday); + } + } + + @Nested + @DisplayName("월간 날짜 범위 계산") + class MonthlyDateRangeTest { + + @Test + @DisplayName("1월의 시작일과 종료일") + void januaryRange() { + // Arrange + LocalDate midJanuary = LocalDate.of(2025, 1, 15); + YearMonth yearMonth = YearMonth.from(midJanuary); + + // Act + LocalDate monthStart = yearMonth.atDay(1); + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + // Assert + assertThat(monthStart).isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat(monthEnd).isEqualTo(LocalDate.of(2025, 1, 31)); + } + + @Test + @DisplayName("윤년 2월의 종료일은 29일") + void leapYearFebruary() { + // Arrange + LocalDate midFebruary = LocalDate.of(2024, 2, 15); // 2024년은 윤년 + YearMonth yearMonth = YearMonth.from(midFebruary); + + // Act + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + // Assert + assertThat(monthEnd).isEqualTo(LocalDate.of(2024, 2, 29)); + } + + @Test + @DisplayName("평년 2월의 종료일은 28일") + void nonLeapYearFebruary() { + // Arrange + LocalDate midFebruary = LocalDate.of(2025, 2, 15); // 2025년은 평년 + YearMonth yearMonth = YearMonth.from(midFebruary); + + // Act + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + // Assert + assertThat(monthEnd).isEqualTo(LocalDate.of(2025, 2, 28)); + } + + @Test + @DisplayName("4월의 종료일은 30일") + void aprilRange() { + // Arrange + LocalDate midApril = LocalDate.of(2025, 4, 15); + YearMonth yearMonth = YearMonth.from(midApril); + + // Act + LocalDate monthEnd = yearMonth.atEndOfMonth(); + + // Assert + assertThat(monthEnd).isEqualTo(LocalDate.of(2025, 4, 30)); + } + } + + @Nested + @DisplayName("상수 값 검증") + class ConstantsTest { + + @Test + @DisplayName("CHUNK_SIZE는 100이다") + void chunkSizeIs100() { + assertThat(RankingJobConstants.CHUNK_SIZE).isEqualTo(100); + } + + @Test + @DisplayName("TOP_N은 100이다") + void topNIs100() { + assertThat(RankingJobConstants.TOP_N).isEqualTo(100); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobTest.java new file mode 100644 index 0000000000..c9b50e8e6e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/batch/job/monthlyranking/MonthlyRankingJobTest.java @@ -0,0 +1,336 @@ +package com.loopers.batch.job.monthlyranking; + +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankMonthlyJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@DisplayName("MonthlyRankingJob 테스트") +class MonthlyRankingJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier("monthlyRankingJob") + private Job monthlyRankingJob; + + @Autowired + private ProductRankMonthlyJpaRepository monthlyRankRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(monthlyRankingJob); + cleanUpTestData(); + } + + private void cleanUpTestData() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + @Nested + @DisplayName("월간 랭킹 집계") + class MonthlyRankingAggregation { + + @Test + @DisplayName("월간 메트릭 데이터를 집계하여 TOP 100 랭킹을 생성한다") + void aggregateMonthlyMetrics() throws Exception { + // Arrange - 2025년 1월 데이터 (1일~31일) + insertDailyMetrics(100L, LocalDate.of(2025, 1, 5), 10, 5, 2, BigDecimal.valueOf(3.0)); + insertDailyMetrics(100L, LocalDate.of(2025, 1, 15), 20, 10, 3, BigDecimal.valueOf(5.5)); + insertDailyMetrics(100L, LocalDate.of(2025, 1, 25), 15, 8, 2, BigDecimal.valueOf(4.0)); + insertDailyMetrics(200L, LocalDate.of(2025, 1, 10), 50, 20, 10, BigDecimal.valueOf(15.0)); + insertDailyMetrics(300L, LocalDate.of(2025, 1, 20), 5, 2, 1, BigDecimal.valueOf(1.5)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + assertThat(rankings).hasSize(3); + + // 1위: product 200 (score: 15.0) + assertThat(rankings.get(0).getProductId()).isEqualTo(200L); + assertThat(rankings.get(0).getRankNumber()).isEqualTo(1); + assertThat(rankings.get(0).getTotalScore()).isEqualByComparingTo(BigDecimal.valueOf(15.0)); + + // 2위: product 100 (score: 3.0 + 5.5 + 4.0 = 12.5) + assertThat(rankings.get(1).getProductId()).isEqualTo(100L); + assertThat(rankings.get(1).getRankNumber()).isEqualTo(2); + assertThat(rankings.get(1).getTotalScore()).isEqualByComparingTo(BigDecimal.valueOf(12.5)); + assertThat(rankings.get(1).getTotalViewCount()).isEqualTo(45); // 10 + 20 + 15 + assertThat(rankings.get(1).getTotalLikeCount()).isEqualTo(23); // 5 + 10 + 8 + assertThat(rankings.get(1).getTotalOrderCount()).isEqualTo(7); // 2 + 3 + 2 + + // 3위: product 300 (score: 1.5) + assertThat(rankings.get(2).getProductId()).isEqualTo(300L); + assertThat(rankings.get(2).getRankNumber()).isEqualTo(3); + } + + @Test + @DisplayName("period_start_date와 period_end_date가 정확히 설정된다 (31일 월)") + void periodDatesForJanuary() throws Exception { + // Arrange - 2025년 1월 (31일) + insertDailyMetrics(100L, LocalDate.of(2025, 1, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getPeriodStartDate()).isEqualTo(LocalDate.of(2025, 1, 1)); + assertThat(rankings.get(0).getPeriodEndDate()).isEqualTo(LocalDate.of(2025, 1, 31)); + } + + @Test + @DisplayName("윤년 2월 말일이 정확히 계산된다") + void leapYearFebruary() throws Exception { + // Arrange - 2024년 2월 (윤년, 29일) + insertDailyMetrics(100L, LocalDate.of(2024, 2, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20240215") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2024, 2, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 2, 1)); + assertThat(rankings.get(0).getPeriodEndDate()).isEqualTo(LocalDate.of(2024, 2, 29)); // 윤년 + } + + @Test + @DisplayName("평년 2월 말일이 정확히 계산된다") + void nonLeapYearFebruary() throws Exception { + // Arrange - 2025년 2월 (평년, 28일) + insertDailyMetrics(100L, LocalDate.of(2025, 2, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250215") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 2, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getPeriodStartDate()).isEqualTo(LocalDate.of(2025, 2, 1)); + assertThat(rankings.get(0).getPeriodEndDate()).isEqualTo(LocalDate.of(2025, 2, 28)); // 평년 + } + + @Test + @DisplayName("30일 월도 정확히 처리된다") + void thirtyDayMonth() throws Exception { + // Arrange - 2025년 4월 (30일) + insertDailyMetrics(100L, LocalDate.of(2025, 4, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250415") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 4, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getPeriodStartDate()).isEqualTo(LocalDate.of(2025, 4, 1)); + assertThat(rankings.get(0).getPeriodEndDate()).isEqualTo(LocalDate.of(2025, 4, 30)); + } + } + + @Nested + @DisplayName("멱등성 보장") + class Idempotency { + + @Test + @DisplayName("동일 월간에 재실행하면 기존 데이터가 갱신된다") + void rerunUpdatesExistingData() throws Exception { + // Arrange - 첫 번째 실행 + insertDailyMetrics(100L, LocalDate.of(2025, 1, 10), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params1 = new JobParametersBuilder() + .addString("targetDate", "20250110") + .addLong("runId", 1L) + .toJobParameters(); + + jobLauncherTestUtils.launchJob(params1); + + // 데이터 추가 + insertDailyMetrics(200L, LocalDate.of(2025, 1, 20), 50, 20, 10, BigDecimal.valueOf(15.0)); + + // Act - 두 번째 실행 + JobParameters params2 = new JobParametersBuilder() + .addString("targetDate", "20250120") + .addLong("runId", 2L) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params2); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + // 중복 없이 2건만 존재 + assertThat(rankings).hasSize(2); + + // 순위가 재계산됨 (200이 1위) + assertThat(rankings.get(0).getProductId()).isEqualTo(200L); + assertThat(rankings.get(0).getRankNumber()).isEqualTo(1); + } + } + + @Nested + @DisplayName("경계 조건") + class BoundaryConditions { + + @Test + @DisplayName("월의 첫날 데이터도 집계에 포함된다") + void firstDayOfMonthIncluded() throws Exception { + // Arrange + insertDailyMetrics(100L, LocalDate.of(2025, 1, 1), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getTotalViewCount()).isEqualTo(10); + } + + @Test + @DisplayName("월의 마지막날 데이터도 집계에 포함된다") + void lastDayOfMonthIncluded() throws Exception { + // Arrange + insertDailyMetrics(100L, LocalDate.of(2025, 1, 31), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getTotalViewCount()).isEqualTo(10); + } + + @Test + @DisplayName("다른 월의 데이터는 집계에서 제외된다") + void otherMonthDataExcluded() throws Exception { + // Arrange - 1월 데이터와 2월 데이터 + insertDailyMetrics(100L, LocalDate.of(2025, 1, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + insertDailyMetrics(200L, LocalDate.of(2025, 2, 15), 50, 20, 10, BigDecimal.valueOf(15.0)); // 제외 대상 + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") // 1월 집계 + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = monthlyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 1)); + + // 1월 데이터만 집계됨 + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getProductId()).isEqualTo(100L); + } + } + + private void insertDailyMetrics(Long productId, LocalDate metricDate, int viewCount, int likeCount, int orderCount, BigDecimal score) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + productId, metricDate, viewCount, likeCount, orderCount, score + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobTest.java new file mode 100644 index 0000000000..f9c09df13d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/batch/job/weeklyranking/WeeklyRankingJobTest.java @@ -0,0 +1,253 @@ +package com.loopers.batch.job.weeklyranking; + +import com.loopers.infrastructure.persistence.jpa.rank.ProductMetricsDailyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaEntity; +import com.loopers.infrastructure.persistence.jpa.rank.ProductRankWeeklyJpaRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@DisplayName("WeeklyRankingJob 테스트") +class WeeklyRankingJobTest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + private Job weeklyRankingJob; + + @Autowired + private ProductRankWeeklyJpaRepository weeklyRankRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void setUp() { + jobLauncherTestUtils.setJob(weeklyRankingJob); + cleanUpTestData(); + } + + private void cleanUpTestData() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + @Nested + @DisplayName("주간 랭킹 집계") + class WeeklyRankingAggregation { + + @Test + @DisplayName("주간 메트릭 데이터를 집계하여 TOP 100 랭킹을 생성한다") + void aggregateWeeklyMetrics() throws Exception { + // Arrange - 2025년 1월 6일(월) ~ 1월 12일(일) 주간 데이터 + insertDailyMetrics(100L, LocalDate.of(2025, 1, 6), 10, 5, 2, BigDecimal.valueOf(3.0)); + insertDailyMetrics(100L, LocalDate.of(2025, 1, 7), 20, 10, 3, BigDecimal.valueOf(5.5)); + insertDailyMetrics(200L, LocalDate.of(2025, 1, 6), 50, 20, 10, BigDecimal.valueOf(15.0)); + insertDailyMetrics(300L, LocalDate.of(2025, 1, 8), 5, 2, 1, BigDecimal.valueOf(1.5)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250108") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 6)); + + assertThat(rankings).hasSize(3); + + // 1위: product 200 (score: 15.0) + assertThat(rankings.get(0).getProductId()).isEqualTo(200L); + assertThat(rankings.get(0).getRankNumber()).isEqualTo(1); + assertThat(rankings.get(0).getTotalScore()).isEqualByComparingTo(BigDecimal.valueOf(15.0)); + + // 2위: product 100 (score: 3.0 + 5.5 = 8.5) + assertThat(rankings.get(1).getProductId()).isEqualTo(100L); + assertThat(rankings.get(1).getRankNumber()).isEqualTo(2); + assertThat(rankings.get(1).getTotalScore()).isEqualByComparingTo(BigDecimal.valueOf(8.5)); + assertThat(rankings.get(1).getTotalViewCount()).isEqualTo(30); // 10 + 20 + assertThat(rankings.get(1).getTotalLikeCount()).isEqualTo(15); // 5 + 10 + assertThat(rankings.get(1).getTotalOrderCount()).isEqualTo(5); // 2 + 3 + + // 3위: product 300 (score: 1.5) + assertThat(rankings.get(2).getProductId()).isEqualTo(300L); + assertThat(rankings.get(2).getRankNumber()).isEqualTo(3); + } + + @Test + @DisplayName("period_start_date와 period_end_date가 정확히 설정된다") + void periodDatesAreCorrect() throws Exception { + // Arrange - 2025년 1월 15일(수) 기준 → 1월 13일(월) ~ 1월 19일(일) + insertDailyMetrics(100L, LocalDate.of(2025, 1, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250115") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 13)); + + assertThat(rankings).hasSize(1); + assertThat(rankings.get(0).getPeriodStartDate()).isEqualTo(LocalDate.of(2025, 1, 13)); // 월요일 + assertThat(rankings.get(0).getPeriodEndDate()).isEqualTo(LocalDate.of(2025, 1, 19)); // 일요일 + } + } + + @Nested + @DisplayName("멱등성 보장") + class Idempotency { + + @Test + @DisplayName("동일 주간에 재실행하면 기존 데이터가 갱신된다") + void rerunUpdatesExistingData() throws Exception { + // Arrange - 첫 번째 실행 + insertDailyMetrics(100L, LocalDate.of(2025, 1, 6), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params1 = new JobParametersBuilder() + .addString("targetDate", "20250106") + .addLong("runId", 1L) + .toJobParameters(); + + jobLauncherTestUtils.launchJob(params1); + + // 데이터 추가 + insertDailyMetrics(200L, LocalDate.of(2025, 1, 7), 50, 20, 10, BigDecimal.valueOf(15.0)); + + // Act - 두 번째 실행 + JobParameters params2 = new JobParametersBuilder() + .addString("targetDate", "20250106") + .addLong("runId", 2L) + .toJobParameters(); + + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params2); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 6)); + + // 중복 없이 2건만 존재 + assertThat(rankings).hasSize(2); + + // 순위가 재계산됨 (200이 1위) + assertThat(rankings.get(0).getProductId()).isEqualTo(200L); + assertThat(rankings.get(0).getRankNumber()).isEqualTo(1); + } + + @Test + @DisplayName("데이터가 없는 주간에 실행하면 빈 결과를 반환한다") + void emptyWeekReturnsNoData() throws Exception { + // Arrange - 다른 주간에만 데이터 존재 + insertDailyMetrics(100L, LocalDate.of(2025, 1, 20), 10, 5, 2, BigDecimal.valueOf(3.0)); + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250106") // 1월 6일 주간에는 데이터 없음 + .addLong("runId", System.currentTimeMillis()) // 고유 파라미터 추가 + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 6)); + + assertThat(rankings).isEmpty(); + } + } + + @Nested + @DisplayName("TOP 100 제한") + class Top100Limit { + + @Test + @DisplayName("100개 초과 상품이 있어도 TOP 100만 저장된다") + void limitsTo100Products() throws Exception { + // Arrange - 150개 상품 데이터 생성 + for (long productId = 1; productId <= 150; productId++) { + insertDailyMetrics( + productId, + LocalDate.of(2025, 1, 6), + (int) productId * 10, + (int) productId * 5, + (int) productId, + BigDecimal.valueOf(productId * 0.1) + ); + } + + JobParameters params = new JobParametersBuilder() + .addString("targetDate", "20250106") + .addLong("runId", System.currentTimeMillis()) + .toJobParameters(); + + // Act + JobExecution jobExecution = jobLauncherTestUtils.launchJob(params); + + // Assert + assertThat(jobExecution.getStatus()).isEqualTo(BatchStatus.COMPLETED); + + List rankings = weeklyRankRepository + .findByPeriodStartDateOrderByRankNumberAsc(LocalDate.of(2025, 1, 6)); + + assertThat(rankings).hasSize(100); + + // 가장 높은 점수의 상품이 1위 (productId 150) + assertThat(rankings.get(0).getProductId()).isEqualTo(150L); + assertThat(rankings.get(0).getRankNumber()).isEqualTo(1); + + // 100위는 productId 51 + assertThat(rankings.get(99).getProductId()).isEqualTo(51L); + assertThat(rankings.get(99).getRankNumber()).isEqualTo(100); + } + } + + private void insertDailyMetrics(Long productId, LocalDate metricDate, int viewCount, int likeCount, int orderCount, BigDecimal score) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + productId, metricDate, viewCount, likeCount, orderCount, score + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/batch/BatchAdminV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/batch/BatchAdminV1ControllerTest.java new file mode 100644 index 0000000000..0e3812d9f2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/batch/BatchAdminV1ControllerTest.java @@ -0,0 +1,126 @@ +package com.loopers.interfaces.api.batch; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("BatchAdminV1Controller 테스트") +class BatchAdminV1ControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + cleanUpTestData(); + } + + private void cleanUpTestData() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics_daily"); + } + + @Nested + @DisplayName("POST /api-admin/v1/batch/weekly-ranking") + class WeeklyRankingJob { + + @Test + @DisplayName("주간 랭킹 Job을 실행하고 성공 응답을 반환한다") + void runWeeklyRankingJob_Success() throws Exception { + // Arrange + insertDailyMetrics(100L, LocalDate.of(2025, 1, 13), 10, 5, 2, BigDecimal.valueOf(3.0)); + + // Act & Assert + mockMvc.perform(post("/api-admin/v1/batch/weekly-ranking") + .param("targetDate", "20250115")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result", is("SUCCESS"))) + .andExpect(jsonPath("$.data.jobName", is("weeklyRankingJob"))) + .andExpect(jsonPath("$.data.status", is("COMPLETED"))) + .andExpect(jsonPath("$.data.executionId", notNullValue())) + .andExpect(jsonPath("$.data.startTime", notNullValue())) + .andExpect(jsonPath("$.data.endTime", notNullValue())); + } + + @Test + @DisplayName("잘못된 targetDate 형식이면 400 에러를 반환한다") + void runWeeklyRankingJob_InvalidDateFormat() throws Exception { + mockMvc.perform(post("/api-admin/v1/batch/weekly-ranking") + .param("targetDate", "2025-01-15")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.meta.result", is("FAIL"))) + .andExpect(jsonPath("$.meta.errorCode", is("BATCH_INVALID_DATE_FORMAT"))); + } + + @Test + @DisplayName("targetDate 파라미터가 없으면 400 에러를 반환한다") + void runWeeklyRankingJob_MissingParameter() throws Exception { + mockMvc.perform(post("/api-admin/v1/batch/weekly-ranking")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("POST /api-admin/v1/batch/monthly-ranking") + class MonthlyRankingJob { + + @Test + @DisplayName("월간 랭킹 Job을 실행하고 성공 응답을 반환한다") + void runMonthlyRankingJob_Success() throws Exception { + // Arrange + insertDailyMetrics(100L, LocalDate.of(2025, 1, 15), 10, 5, 2, BigDecimal.valueOf(3.0)); + + // Act & Assert + mockMvc.perform(post("/api-admin/v1/batch/monthly-ranking") + .param("targetDate", "20250115")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.meta.result", is("SUCCESS"))) + .andExpect(jsonPath("$.data.jobName", is("monthlyRankingJob"))) + .andExpect(jsonPath("$.data.status", is("COMPLETED"))) + .andExpect(jsonPath("$.data.executionId", notNullValue())) + .andExpect(jsonPath("$.data.startTime", notNullValue())) + .andExpect(jsonPath("$.data.endTime", notNullValue())); + } + + @Test + @DisplayName("잘못된 targetDate 형식이면 400 에러를 반환한다") + void runMonthlyRankingJob_InvalidDateFormat() throws Exception { + mockMvc.perform(post("/api-admin/v1/batch/monthly-ranking") + .param("targetDate", "invalid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.meta.result", is("FAIL"))) + .andExpect(jsonPath("$.meta.errorCode", is("BATCH_INVALID_DATE_FORMAT"))); + } + } + + private void insertDailyMetrics(Long productId, LocalDate metricDate, int viewCount, int likeCount, int orderCount, BigDecimal score) { + jdbcTemplate.update( + """ + INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + """, + productId, metricDate, viewCount, likeCount, orderCount, score + ); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1PeriodApiTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1PeriodApiTest.java new file mode 100644 index 0000000000..3b65cb0123 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1PeriodApiTest.java @@ -0,0 +1,297 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.config.TestRedisConfiguration; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestRedisConfiguration.class) +@DisplayName("랭킹 API 기간별 조회 테스트") +class RankingV1PeriodApiTest { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Brand testBrand; + private Product product1; + private Product product2; + private Product product3; + + @BeforeEach + void setUp() { + cleanUpRedisKeys(); + cleanUpRankingTables(); + + testBrand = brandRepository.save(Brand.create("Test Brand", "Test", null)); + product1 = productRepository.save(Product.create(testBrand.getId(), "Product 1", "Desc", new Money(10000), new Stock(100), null)); + product2 = productRepository.save(Product.create(testBrand.getId(), "Product 2", "Desc", new Money(20000), new Stock(100), null)); + product3 = productRepository.save(Product.create(testBrand.getId(), "Product 3", "Desc", new Money(30000), new Stock(100), null)); + } + + @AfterEach + void tearDown() { + cleanUpRedisKeys(); + cleanUpRankingTables(); + databaseCleanUp.truncateAllTables(); + } + + private void cleanUpRedisKeys() { + Set keys = redisTemplate.keys("ranking:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + private void cleanUpRankingTables() { + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + } + + private String getTodayKey() { + return "ranking:all:" + LocalDate.now().format(DATE_FORMATTER); + } + + @Nested + @DisplayName("GET /api/v1/rankings - period 파라미터") + class PeriodRankings { + + @Test + @DisplayName("period 미지정 시 기본값 DAILY로 동작한다") + void defaultPeriodIsDaily() { + // Arrange + String today = LocalDate.now().format(DATE_FORMATTER); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=" + today, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().period().name()).isEqualTo("DAILY"); + assertThat(response.getBody().data().date()).isEqualTo(today); + } + + @Test + @DisplayName("period=DAILY로 일간 랭킹을 조회한다") + void getDailyRankings() { + // Arrange + String today = LocalDate.now().format(DATE_FORMATTER); + String key = getTodayKey(); + redisTemplate.opsForZSet().add(key, String.valueOf(product1.getId()), 10.0); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=" + today + "&period=DAILY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().period().name()).isEqualTo("DAILY"); + assertThat(response.getBody().data().periodStart()).isEqualTo(today); + assertThat(response.getBody().data().periodEnd()).isEqualTo(today); + assertThat(response.getBody().data().rankings()).hasSize(1); + } + + @Test + @DisplayName("period=WEEKLY로 주간 랭킹을 조회한다") + void getWeeklyRankings() { + // Arrange - 주간 랭킹 데이터 삽입 + LocalDate weekStart = LocalDate.of(2025, 1, 13); // 월요일 + LocalDate weekEnd = LocalDate.of(2025, 1, 19); // 일요일 + insertWeeklyRanking(product1.getId(), 1, weekStart, weekEnd, BigDecimal.valueOf(15.0), 50, 20, 10); + insertWeeklyRanking(product2.getId(), 2, weekStart, weekEnd, BigDecimal.valueOf(12.5), 45, 18, 7); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=20250115&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().period().name()).isEqualTo("WEEKLY"); + assertThat(response.getBody().data().periodStart()).isEqualTo("20250113"); + assertThat(response.getBody().data().periodEnd()).isEqualTo("20250119"); + assertThat(response.getBody().data().rankings()).hasSize(2); + + // 1위 + assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1); + assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(product1.getId()); + assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("Product 1"); + assertThat(response.getBody().data().rankings().get(0).viewCount()).isEqualTo(50); + assertThat(response.getBody().data().rankings().get(0).likeCount()).isEqualTo(20); + assertThat(response.getBody().data().rankings().get(0).orderCount()).isEqualTo(10); + + // 2위 + assertThat(response.getBody().data().rankings().get(1).rank()).isEqualTo(2); + assertThat(response.getBody().data().rankings().get(1).productId()).isEqualTo(product2.getId()); + } + + @Test + @DisplayName("period=MONTHLY로 월간 랭킹을 조회한다") + void getMonthlyRankings() { + // Arrange - 월간 랭킹 데이터 삽입 + LocalDate monthStart = LocalDate.of(2025, 1, 1); + LocalDate monthEnd = LocalDate.of(2025, 1, 31); + insertMonthlyRanking(product3.getId(), 1, monthStart, monthEnd, BigDecimal.valueOf(100.0), 500, 200, 50); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=20250115&period=MONTHLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().period().name()).isEqualTo("MONTHLY"); + assertThat(response.getBody().data().periodStart()).isEqualTo("20250101"); + assertThat(response.getBody().data().periodEnd()).isEqualTo("20250131"); + assertThat(response.getBody().data().rankings()).hasSize(1); + assertThat(response.getBody().data().rankings().get(0).rank()).isEqualTo(1); + assertThat(response.getBody().data().rankings().get(0).productId()).isEqualTo(product3.getId()); + assertThat(response.getBody().data().rankings().get(0).productName()).isEqualTo("Product 3"); + } + + @Test + @DisplayName("주간 랭킹 페이징이 정상 동작한다") + void weeklyRankingPagination() { + // Arrange - 3개의 주간 랭킹 데이터 + LocalDate weekStart = LocalDate.of(2025, 1, 13); + LocalDate weekEnd = LocalDate.of(2025, 1, 19); + insertWeeklyRanking(product1.getId(), 1, weekStart, weekEnd, BigDecimal.valueOf(15.0), 50, 20, 10); + insertWeeklyRanking(product2.getId(), 2, weekStart, weekEnd, BigDecimal.valueOf(12.5), 45, 18, 7); + insertWeeklyRanking(product3.getId(), 3, weekStart, weekEnd, BigDecimal.valueOf(10.0), 40, 15, 5); + + // Act & Assert - 페이지 1 (size=2) + ResponseEntity> response1 = testRestTemplate.exchange( + "/api/v1/rankings?date=20250115&period=WEEKLY&size=2&page=1", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response1.getBody()).isNotNull(); + assertThat(response1.getBody().data().rankings()).hasSize(2); + assertThat(response1.getBody().data().rankings().get(0).rank()).isEqualTo(1); + assertThat(response1.getBody().data().rankings().get(1).rank()).isEqualTo(2); + assertThat(response1.getBody().data().page()).isEqualTo(1); + assertThat(response1.getBody().data().size()).isEqualTo(2); + assertThat(response1.getBody().data().totalCount()).isEqualTo(3); + assertThat(response1.getBody().data().totalPages()).isEqualTo(2); + + // Act & Assert - 페이지 2 (size=2) + ResponseEntity> response2 = testRestTemplate.exchange( + "/api/v1/rankings?date=20250115&period=WEEKLY&size=2&page=2", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response2.getBody()).isNotNull(); + assertThat(response2.getBody().data().rankings()).hasSize(1); + assertThat(response2.getBody().data().rankings().get(0).rank()).isEqualTo(3); + } + + @Test + @DisplayName("해당 기간에 데이터가 없으면 빈 목록을 반환한다") + void emptyRankingsForPeriod() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/rankings?date=20250101&period=WEEKLY", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().data().rankings()).isEmpty(); + assertThat(response.getBody().data().totalCount()).isEqualTo(0); + } + } + + private void insertWeeklyRanking(Long productId, int rank, LocalDate periodStart, LocalDate periodEnd, + BigDecimal score, long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_weekly + (product_id, rank_number, total_score, total_view_count, total_like_count, total_order_count, period_start_date, period_end_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + """, + productId, rank, score, viewCount, likeCount, orderCount, periodStart, periodEnd + ); + } + + private void insertMonthlyRanking(Long productId, int rank, LocalDate periodStart, LocalDate periodEnd, + BigDecimal score, long viewCount, long likeCount, long orderCount) { + jdbcTemplate.update( + """ + INSERT INTO mv_product_rank_monthly + (product_id, rank_number, total_score, total_view_count, total_like_count, total_order_count, period_start_date, period_end_date, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) + """, + productId, rank, score, viewCount, likeCount, orderCount, periodStart, periodEnd + ); + } +} \ No newline at end of file diff --git a/scripts/test-data/ranking_test_data.sql b/scripts/test-data/ranking_test_data.sql new file mode 100644 index 0000000000..767704e7c7 --- /dev/null +++ b/scripts/test-data/ranking_test_data.sql @@ -0,0 +1,113 @@ +-- 랭킹 파이프라인 검증용 테스트 데이터 +-- 사용법: MySQL에서 직접 실행하거나 테스트 코드에서 사용 + +-- 기존 테스트 데이터 정리 +DELETE FROM mv_product_rank_weekly; +DELETE FROM mv_product_rank_monthly; +DELETE FROM product_metrics_daily; + +-- ============================================================ +-- 2025년 4월 기준 테스트 데이터 (30일치) +-- 상품 ID 1~200, 일별 메트릭 +-- ============================================================ + +-- 상위 랭킹 보장 상품 (검증용) +-- Product 1: 일관되게 높은 점수 (예상 1위) +-- Product 2: 두 번째로 높은 점수 (예상 2위) +-- Product 3: 세 번째로 높은 점수 (예상 3위) + +-- 프로시저 없이 INSERT로 테스트 데이터 생성 +-- 2025년 4월 1일 ~ 4월 30일 + +-- Product 1: 최상위 상품 (모든 날짜에 높은 점수) +INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) +SELECT 1, DATE_ADD('2025-04-01', INTERVAL n DAY), + 100 + FLOOR(RAND() * 50), -- view: 100~150 + 50 + FLOOR(RAND() * 20), -- like: 50~70 + 20 + FLOOR(RAND() * 10), -- order: 20~30 + 25.0 + RAND() * 5, -- score: 25~30 + NOW(), NOW() +FROM ( + SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 + UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 + UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 + UNION SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 + UNION SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 + UNION SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 +) AS days +WHERE DATE_ADD('2025-04-01', INTERVAL n DAY) <= '2025-04-30'; + +-- Product 2: 2위 예상 상품 +INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) +SELECT 2, DATE_ADD('2025-04-01', INTERVAL n DAY), + 80 + FLOOR(RAND() * 40), -- view: 80~120 + 40 + FLOOR(RAND() * 15), -- like: 40~55 + 15 + FLOOR(RAND() * 8), -- order: 15~23 + 20.0 + RAND() * 4, -- score: 20~24 + NOW(), NOW() +FROM ( + SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 + UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 + UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 + UNION SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 + UNION SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 + UNION SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 +) AS days +WHERE DATE_ADD('2025-04-01', INTERVAL n DAY) <= '2025-04-30'; + +-- Product 3: 3위 예상 상품 +INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) +SELECT 3, DATE_ADD('2025-04-01', INTERVAL n DAY), + 60 + FLOOR(RAND() * 30), -- view: 60~90 + 30 + FLOOR(RAND() * 10), -- like: 30~40 + 10 + FLOOR(RAND() * 5), -- order: 10~15 + 15.0 + RAND() * 3, -- score: 15~18 + NOW(), NOW() +FROM ( + SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 + UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 + UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 + UNION SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 + UNION SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 + UNION SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 +) AS days +WHERE DATE_ADD('2025-04-01', INTERVAL n DAY) <= '2025-04-30'; + +-- Products 4~50: 중간 순위 상품들 (랜덤 점수) +INSERT INTO product_metrics_daily (product_id, metric_date, view_count, like_count, order_count, score, created_at, updated_at) +SELECT product_id, DATE_ADD('2025-04-01', INTERVAL n DAY), + 20 + FLOOR(RAND() * 50), -- view: 20~70 + 10 + FLOOR(RAND() * 20), -- like: 10~30 + 2 + FLOOR(RAND() * 8), -- order: 2~10 + 5.0 + RAND() * 8, -- score: 5~13 + NOW(), NOW() +FROM ( + SELECT 4 AS product_id UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 + UNION SELECT 9 UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 + UNION SELECT 14 UNION SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 + UNION SELECT 19 UNION SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 + UNION SELECT 24 UNION SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 + UNION SELECT 29 UNION SELECT 30 UNION SELECT 31 UNION SELECT 32 UNION SELECT 33 + UNION SELECT 34 UNION SELECT 35 UNION SELECT 36 UNION SELECT 37 UNION SELECT 38 + UNION SELECT 39 UNION SELECT 40 UNION SELECT 41 UNION SELECT 42 UNION SELECT 43 + UNION SELECT 44 UNION SELECT 45 UNION SELECT 46 UNION SELECT 47 UNION SELECT 48 + UNION SELECT 49 UNION SELECT 50 +) AS products +CROSS JOIN ( + SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 + UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 + UNION SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 + UNION SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 + UNION SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 + UNION SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 +) AS days +WHERE DATE_ADD('2025-04-01', INTERVAL n DAY) <= '2025-04-30'; + +-- 데이터 확인 +SELECT 'Total Records' AS info, COUNT(*) AS count FROM product_metrics_daily WHERE metric_date BETWEEN '2025-04-01' AND '2025-04-30'; +SELECT product_id, SUM(score) AS total_score, SUM(view_count) AS total_views +FROM product_metrics_daily +WHERE metric_date BETWEEN '2025-04-01' AND '2025-04-30' +GROUP BY product_id +ORDER BY total_score DESC +LIMIT 10; \ No newline at end of file From 3d6dc3d8ac3dd60380907f6d6dcd04b7489dd5fa Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:20:18 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs(ranking):=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EC=9D=98=EC=82=AC=EA=B2=B0=EC=A0=95=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 의사결정 5가지 기록: 1. Batch 처리 방식: Chunk-Oriented 선택 (vs Tasklet) 2. MV 갱신 전략: DELETE + INSERT 선택 (vs UPSERT) 3. 일간 데이터 소스: Redis 유지 (vs MV 통일) 4. API 분기 전략: Switch 분기 선택 (vs Strategy 패턴) 5. Batch Job: 분리 선택 (vs 통합) 각 의사결정에 대해 선택지, 장단점, 근거, 향후 개선 포인트 기록 Co-Authored-By: Claude Opus 4.5 --- .docs/ranking-batch-design-decisions.md | 212 ++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 .docs/ranking-batch-design-decisions.md diff --git a/.docs/ranking-batch-design-decisions.md b/.docs/ranking-batch-design-decisions.md new file mode 100644 index 0000000000..32fe6b93e1 --- /dev/null +++ b/.docs/ranking-batch-design-decisions.md @@ -0,0 +1,212 @@ +# 랭킹 배치 시스템 설계 의사결정 + +이 문서는 랭킹 배치 파이프라인 구현 과정에서 내린 주요 설계 의사결정을 기록합니다. + +--- + +## 1. Batch 처리 방식: Chunk-Oriented vs Tasklet + +### 선택지 + +| 옵션 | 설명 | +|------|------| +| A. Chunk-Oriented | Reader → Processor → Writer 패턴 | +| B. Tasklet | 단일 `INSERT INTO SELECT` 쿼리 | + +### 장단점 비교 + +| 관점 | Chunk-Oriented (A) | Tasklet (B) | +|------|-------------------|-------------| +| **메모리 효율** | 청크 단위 처리로 대용량 가능 | 전체 데이터 한 번에 처리 | +| **구현 복잡도** | 높음 (Reader/Processor/Writer 분리) | 낮음 (SQL 한 줄) | +| **확장성** | Processor에서 로직 추가 용이 | SQL 수정 필요 | +| **장애 대응** | 청크 단위 재시작 가능 | 전체 롤백 | +| **디버깅** | 각 단계별 로깅/모니터링 가능 | SQL 결과로만 확인 | + +### 최종 선택: **A. Chunk-Oriented** + +**근거:** +1. 상품 수가 수만~수십만 개로 증가할 경우 메모리 이슈 방지 +2. Processor에서 rank_number 부여 로직 분리 (단위 테스트 용이) +3. Spring Batch의 재시작, 스킵, 리스너 기능 활용 가능 +4. 각 단계별 로깅으로 운영 모니터링 편의 + +**트레이드오프:** +- Tasklet 대비 코드량 증가 +- 단순 집계에는 과한 구조일 수 있음 + +**향후 개선:** +- 대용량 처리 시 `JdbcPagingItemReader` + 파티셔닝 검토 + +--- + +## 2. MV 갱신 전략: DELETE + INSERT vs MERGE (UPSERT) + +### 선택지 + +| 옵션 | 설명 | +|------|------| +| A. DELETE + INSERT | 해당 기간 전체 삭제 후 새로 삽입 | +| B. UPSERT | `ON DUPLICATE KEY UPDATE` 활용 | + +### 장단점 비교 + +| 관점 | DELETE + INSERT (A) | UPSERT (B) | +|------|--------------------| -----------| +| **멱등성** | 완벽 보장 | 보장 (단, 삭제된 상품 처리 주의) | +| **구현 복잡도** | 단순 | UNIQUE 제약 조건 관리 필요 | +| **성능 (삽입)** | Bulk INSERT 최적화 가능 | 행마다 중복 체크 | +| **성능 (갱신)** | 전체 재생성 | 변경분만 갱신 | +| **데이터 정합성** | 항상 최신 스냅샷 | 삭제된 상품 잔류 가능 | + +### 최종 선택: **A. DELETE + INSERT** + +**근거:** +1. TOP 100 랭킹은 전체가 변경될 수 있어 "변경분만 갱신" 이점이 작음 +2. 삭제된 상품이 랭킹에 남는 문제 원천 차단 +3. 구현 단순화 (`deleteByPeriodStartDate()` + `saveAll()`) +4. 청크 처리에서 첫 번째 청크 Write 시점에 DELETE 실행으로 멱등성 보장 + +**트레이드오프:** +- 데이터가 많아지면 DELETE 비용 증가 +- 삭제 후 INSERT 전 시점에 순간적 데이터 부재 (읽기 일관성) + +**향후 개선:** +- 읽기 일관성이 중요해지면 새 파티션에 INSERT 후 파티션 스왑 검토 + +--- + +## 3. 일간 랭킹 데이터 소스: Redis vs MV 테이블 + +### 선택지 + +| 옵션 | 설명 | +|------|------| +| A. Redis 유지 | 일간은 기존 Redis ZSET, 주간/월간만 MV | +| B. MV 통일 | 일간도 product_metrics_daily에서 배치 집계 | + +### 장단점 비교 + +| 관점 | Redis 유지 (A) | MV 통일 (B) | +|------|---------------|-------------| +| **실시간성** | 즉시 반영 | 배치 주기만큼 지연 | +| **아키텍처 일관성** | 이원화 (Redis + DB) | 단일화 (DB만) | +| **운영 복잡도** | Redis 장애 대응 필요 | DB만 관리 | +| **응답 형식 통일** | viewCount 등 null | 모든 필드 제공 가능 | +| **쓰기 부하** | Redis (빠름) | DB (상대적 느림) | + +### 최종 선택: **A. Redis 유지** + +**근거:** +1. 일간 랭킹은 사용자 행동(조회, 좋아요, 주문)에 즉시 반영되어야 함 +2. 이벤트 기반 점수 갱신(`ZINCRBY`)이 Redis에 최적화됨 +3. 일간 배치를 추가하면 배치 Job이 3개로 늘어나 운영 부담 증가 +4. 기존 인프라(Redis) 활용으로 추가 리소스 불필요 + +**트레이드오프:** +- 일간/주간/월간 응답 필드가 다름 (일간은 viewCount 등 null) +- Redis 장애 시 일간 랭킹 불가능 + +**향후 개선:** +- Redis 장애 시 product_metrics_daily 기반 폴백 쿼리 추가 검토 +- 응답 필드 통일 필요 시 일간도 MV 테이블 도입 고려 + +--- + +## 4. API 분기 전략: Strategy 패턴 vs Switch 분기 + +### 선택지 + +| 옵션 | 설명 | +|------|------| +| A. Strategy 패턴 | `RankingStrategy` 인터페이스 + DailyStrategy/WeeklyStrategy/MonthlyStrategy | +| B. Switch 분기 | Service 내 `switch (period)` 로 분기 | + +### 장단점 비교 + +| 관점 | Strategy (A) | Switch (B) | +|------|-------------|-----------| +| **코드량** | 많음 (인터페이스 + 3개 구현체) | 적음 (한 파일 내) | +| **확장성** | 새 기간 추가 시 클래스만 추가 | switch에 case 추가 | +| **테스트** | 각 Strategy 독립 테스트 | 통합 테스트 위주 | +| **런타임 유연성** | DI로 동적 선택 가능 | 컴파일 타임 결정 | +| **학습 비용** | 패턴 이해 필요 | 직관적 | + +### 최종 선택: **B. Switch 분기** + +**근거:** +1. 현재 분기가 3개(DAILY, WEEKLY, MONTHLY)로 적음 +2. 각 분기의 로직이 거의 동일 (날짜 범위 계산 + Repository 호출) +3. 오버엔지니어링 방지 (YAGNI 원칙) +4. 코드 가독성 및 유지보수 용이 + +**코드 예시:** +```java +public List getPeriodRankings(LocalDate date, RankingPeriod period, ...) { + return switch (period) { + case DAILY -> getDailyPeriodRankings(date, ...); + case WEEKLY -> getWeeklyPeriodRankings(date, ...); + case MONTHLY -> getMonthlyPeriodRankings(date, ...); + }; +} +``` + +**향후 개선:** +- 기간 타입이 5개 이상으로 늘거나, 각 타입별 복잡한 비즈니스 로직이 필요해지면 Strategy 패턴 리팩토링 검토 +- 이 결정 사항은 `RankingPeriod.java`에 주석으로 문서화됨 + +--- + +## 5. Batch Job 분리 vs 통합 + +### 선택지 + +| 옵션 | 설명 | +|------|------| +| A. Job 분리 | `weeklyRankingJob`, `monthlyRankingJob` 별도 정의 | +| B. Job 통합 | `rankingAggregationJob` 하나에 period 파라미터로 분기 | + +### 장단점 비교 + +| 관점 | Job 분리 (A) | Job 통합 (B) | +|------|-------------|-------------| +| **운영 명확성** | Job 이름만으로 역할 구분 | 파라미터까지 확인 필요 | +| **스케줄링** | 각각 독립적 cron 설정 | 조건부 로직 필요 | +| **코드 재사용** | 공통 클래스 추출로 해결 | 자연스러운 재사용 | +| **장애 격리** | 한 Job 실패가 다른 Job에 영향 없음 | Step 단위로 격리 가능 | +| **모니터링** | Job별 독립 메트릭 | 파라미터로 필터링 | + +### 최종 선택: **A. Job 분리** + +**근거:** +1. 주간/월간 배치 실행 주기가 다름 (주간: 매주 월요일, 월간: 매월 1일) +2. Job 이름으로 운영 담당자가 쉽게 식별 가능 +3. Admin API에서 `/weekly-ranking`, `/monthly-ranking` 으로 명확한 엔드포인트 +4. 한 Job 실패 시 다른 Job에 영향 없음 + +**공통 로직 추출:** +``` +batch/job/common/ +├── RankingJobConstants.java # 상수, SQL 템플릿 +└── RankingMetricsAggregation.java # 집계 DTO + RowMapper +``` + +**트레이드오프:** +- Job 설정 파일이 2개로 늘어남 +- Reader/Processor/Writer 로직 일부 중복 + +**향후 개선:** +- 분기별(QUARTERLY), 연간(YEARLY) 랭킹 추가 시 동일 패턴으로 Job 추가 +- 공통 로직이 더 많아지면 `AbstractRankingJobConfig` 추상 클래스 검토 + +--- + +## 요약 + +| 의사결정 | 최종 선택 | 핵심 근거 | +|----------|----------|-----------| +| Batch 처리 방식 | Chunk-Oriented | 대용량 처리, 확장성, 재시작 지원 | +| MV 갱신 전략 | DELETE + INSERT | 멱등성 완벽 보장, 구현 단순 | +| 일간 데이터 소스 | Redis 유지 | 실시간성 우선 | +| API 분기 전략 | Switch 분기 | 현재 복잡도에 적합, YAGNI | +| Batch Job | 분리 | 운영 명확성, 장애 격리 | \ No newline at end of file From 4487233b0ba206c0038e44b81030fb4a8e0b3b35 Mon Sep 17 00:00:00 2001 From: dame2 Date: Fri, 17 Apr 2026 03:20:23 +0900 Subject: [PATCH 7/7] =?UTF-8?q?chore(config):=20application.yml=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- apps/commerce-api/src/main/resources/application.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 7c75b8b0f6..dd71970770 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -28,6 +28,7 @@ spring: - monitoring.yml - queue.yml - ranking.yml + - batch.yml springdoc: use-fqn: true