From d4cecccb34b505a85fbe617806b98527a10e29d4 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Fri, 2 Jan 2026 03:03:57 +0900 Subject: [PATCH 1/2] Feature/batch (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: batch 처리 모듇 분리 * feat: batch 모듈에 ProductMetrics 도메인 추가 * feat: ProudctMetrics의 Repository 추가 * test: Product Metrics 배치 작업에 대한 테스트 코드 추가 * feat: ProductMetrics 배치 작업 구현 * test: Product Rank에 대한 테스트 코드 추가 * feat: Product Rank 도메인 구현 * feat: Product Rank Repository 추가 * test: Product Rank 배치에 대한 테스트 코드 추가 * feat: Product Rank 배치 작업 추가 * feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가 * refractor: 랭킹 집계 로직을 여러 step으로 분리함 * chore: db 초기화 로직에서 발생하는 오류 수정 * test: 랭킹 집계의 각 step에 대한 테스트 코드 추가 --- apps/commerce-api/build.gradle.kts | 3 - .../application/ranking/RankingService.java | 182 ++++++++++++ .../com/loopers/domain/rank/ProductRank.java | 119 ++++++++ .../domain/rank/ProductRankRepository.java | 39 +++ .../rank/ProductRankRepositoryImpl.java | 63 +++++ .../api/ranking/RankingV1Controller.java | 39 ++- .../src/main/resources/application.yml | 5 - apps/commerce-batch/build.gradle.kts | 22 ++ .../java/com/loopers/BatchApplication.java | 34 +++ .../domain/metrics/ProductMetrics.java | 134 +++++++++ .../metrics/ProductMetricsRepository.java | 86 ++++++ .../com/loopers/domain/rank/ProductRank.java | 166 +++++++++++ .../domain/rank/ProductRankRepository.java | 59 ++++ .../loopers/domain/rank/ProductRankScore.java | 141 ++++++++++ .../rank/ProductRankScoreRepository.java | 68 +++++ .../metrics/ProductMetricsItemProcessor.java | 45 +++ .../metrics/ProductMetricsItemReader.java | 111 ++++++++ .../metrics/ProductMetricsItemWriter.java | 58 ++++ .../metrics/ProductMetricsJobConfig.java | 148 ++++++++++ .../rank/ProductRankAggregationProcessor.java | 74 +++++ .../rank/ProductRankAggregationReader.java | 123 ++++++++ .../rank/ProductRankCalculationProcessor.java | 87 ++++++ .../rank/ProductRankCalculationReader.java | 72 +++++ .../rank/ProductRankCalculationWriter.java | 82 ++++++ .../batch/rank/ProductRankJobConfig.java | 257 +++++++++++++++++ .../ProductRankScoreAggregationWriter.java | 170 +++++++++++ .../metrics/ProductMetricsJpaRepository.java | 58 ++++ .../metrics/ProductMetricsRepositoryImpl.java | 73 +++++ .../rank/ProductRankRepositoryImpl.java | 95 +++++++ .../rank/ProductRankScoreRepositoryImpl.java | 100 +++++++ .../src/main/resources/application.yml | 43 +++ .../domain/metrics/ProductMetricsTest.java | 217 +++++++++++++++ .../loopers/domain/rank/ProductRankTest.java | 235 ++++++++++++++++ .../ProductMetricsItemProcessorTest.java | 87 ++++++ .../metrics/ProductMetricsItemReaderTest.java | 134 +++++++++ .../metrics/ProductMetricsItemWriterTest.java | 118 ++++++++ .../ProductRankAggregationProcessorTest.java | 121 ++++++++ .../ProductRankAggregationReaderTest.java | 152 ++++++++++ .../ProductRankCalculationProcessorTest.java | 263 ++++++++++++++++++ ...ProductRankScoreAggregationWriterTest.java | 251 +++++++++++++++++ .../com/loopers/utils/DatabaseCleanUp.java | 18 +- settings.gradle.kts | 1 + 42 files changed, 4342 insertions(+), 11 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 3ba4f7df5..f4d3b583a 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -24,9 +24,6 @@ dependencies { implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현 implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") - // batch - implementation("org.springframework.boot:spring-boot-starter-batch") - // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index df6305b83..d4b0d38d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -46,10 +46,49 @@ public class RankingService { private final ProductService productService; private final BrandService brandService; private final RankingSnapshotService rankingSnapshotService; + private final com.loopers.domain.rank.ProductRankRepository productRankRepository; /** * 랭킹을 조회합니다 (페이징). *

+ * 기간별(일간/주간/월간) 랭킹을 조회합니다. + *

+ *

+ * 기간별 조회 방식: + *

+ *

+ *

+ * Graceful Degradation (DAILY만 적용): + *

+ *

+ * + * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate) + * @param periodType 기간 타입 (DAILY, WEEKLY, MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + @Transactional(readOnly = true) + public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) { + if (periodType == PeriodType.DAILY) { + // 일간 랭킹: 기존 Redis 방식 + return getRankings(date, page, size); + } else { + // 주간/월간 랭킹: Materialized View에서 조회 + return getRankingsFromMaterializedView(date, periodType, page, size); + } + } + + /** + * 랭킹을 조회합니다 (페이징) - 일간 랭킹 전용. + *

* ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다. *

*

@@ -304,6 +343,149 @@ private Long getProductRankFromRedis(Long productId, LocalDate date) { return rank + 1; } + /** + * Materialized View에서 주간/월간 랭킹을 조회합니다. + *

+ * Materialized View에 저장된 TOP 100 랭킹을 조회하고, 상품 정보를 Aggregation하여 반환합니다. + *

+ * + * @param date 기준 날짜 + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + private RankingsResponse getRankingsFromMaterializedView( + LocalDate date, + PeriodType periodType, + int page, + int size + ) { + // 기간 시작일 계산 + LocalDate periodStartDate; + if (periodType == PeriodType.WEEKLY) { + // 주간: 해당 주의 월요일 + periodStartDate = date.with(java.time.DayOfWeek.MONDAY); + } else { + // 월간: 해당 월의 1일 + periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth()); + } + + // Materialized View에서 랭킹 조회 + com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType = + periodType == PeriodType.WEEKLY + ? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY + : com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY; + + List ranks = productRankRepository.findByPeriod( + rankPeriodType, periodStartDate, 100 + ); + + if (ranks.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 페이징 처리 + long start = (long) page * size; + long end = Math.min(start + size, ranks.size()); + + if (start >= ranks.size()) { + return RankingsResponse.empty(page, size); + } + + List pagedRanks = ranks.subList((int) start, (int) end); + + // 상품 ID 추출 + List productIds = pagedRanks.stream() + .map(com.loopers.domain.rank.ProductRank::getProductId) + .toList(); + + // 상품 정보 배치 조회 + List products = productService.getProducts(productIds); + + // 상품 ID → Product Map 생성 + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 + List rankingItems = new ArrayList<>(); + for (com.loopers.domain.rank.ProductRank rank : pagedRanks) { + Long productId = rank.getProductId(); + Product product = productMap.get(productId); + + if (product == null) { + log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId); + continue; + } + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + productId, product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + rank.getLikeCount() + ); + + // 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산) + double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount()); + + rankingItems.add(new RankingItem( + rank.getRank().longValue(), + score, + productDetail + )); + } + + boolean hasNext = end < ranks.size(); + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 종합 점수를 계산합니다. + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return (likeCount != null ? likeCount : 0L) * 0.3 + + (salesCount != null ? salesCount : 0L) * 0.5 + + (viewCount != null ? viewCount : 0L) * 0.2; + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + DAILY, // 일간 + WEEKLY, // 주간 + MONTHLY // 월간 + } + /** * 랭킹 조회 결과. * diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..30abae5d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,119 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

+ *

+ * Materialized View 설계: + *

    + *
  • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
  • + *
  • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
  • + *
  • TOP 100만 저장하여 조회 성능 최적화
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
  • + *
  • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
    + *
  • 주간: 해당 주의 월요일 (ISO 8601 기준)
  • + *
  • 월간: 해당 월의 1일
  • + *
+ */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..92b1529e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..046c6a035 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } +} + 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 ecbae6157..2a34d7f21 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 @@ -33,10 +33,19 @@ public class RankingV1Controller { /** * 랭킹을 조회합니다. *

- * 날짜별 랭킹을 페이징하여 조회합니다. + * 기간별(일간/주간/월간) 랭킹을 페이징하여 조회합니다. + *

+ *

+ * 기간 타입: + *

    + *
  • DAILY: 일간 랭킹 (Redis ZSET에서 조회)
  • + *
  • WEEKLY: 주간 랭킹 (Materialized View에서 조회)
  • + *
  • MONTHLY: 월간 랭킹 (Materialized View에서 조회)
  • + *
*

* * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜) + * @param period 기간 타입 (DAILY, WEEKLY, MONTHLY, 기본값: DAILY) * @param page 페이지 번호 (기본값: 0) * @param size 페이지당 항목 수 (기본값: 20) * @return 랭킹 목록을 담은 API 응답 @@ -44,12 +53,16 @@ public class RankingV1Controller { @GetMapping public ApiResponse getRankings( @RequestParam(required = false) String date, + @RequestParam(required = false, defaultValue = "DAILY") String period, @RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "20") int size ) { // 날짜 파라미터 검증 및 기본값 처리 LocalDate targetDate = parseDate(date); + // 기간 타입 파싱 및 검증 + RankingService.PeriodType periodType = parsePeriodType(period); + // 페이징 검증 if (page < 0) { page = 0; @@ -61,7 +74,7 @@ public ApiResponse getRankings( size = 100; // 최대 100개로 제한 } - RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size); + RankingService.RankingsResponse result = rankingService.getRankings(targetDate, periodType, page, size); return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result)); } @@ -86,4 +99,26 @@ private LocalDate parseDate(String dateStr) { return LocalDate.now(ZoneId.of("UTC")); } } + + /** + * 기간 타입 문자열을 PeriodType으로 파싱합니다. + *

+ * 파싱 실패 시 DAILY를 반환합니다. + *

+ * + * @param periodStr 기간 타입 문자열 (DAILY, WEEKLY, MONTHLY) + * @return 파싱된 기간 타입 (실패 시 DAILY) + */ + private RankingService.PeriodType parsePeriodType(String periodStr) { + if (periodStr == null || periodStr.isBlank()) { + return RankingService.PeriodType.DAILY; + } + + try { + return RankingService.PeriodType.valueOf(periodStr.toUpperCase()); + } catch (IllegalArgumentException e) { + // 파싱 실패 시 DAILY 반환 + return RankingService.PeriodType.DAILY; + } + } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 584ba6335..0856b8d81 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -24,11 +24,6 @@ spring: - redis.yml - logging.yml - monitoring.yml - batch: - jdbc: - initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 (임시: production 배포 전 EDA로 교체 예정) - job: - enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화 payment-gateway: url: http://localhost:8082 diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..1d691a669 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl (필요시) + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java new file mode 100644 index 000000000..76619b777 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java @@ -0,0 +1,34 @@ +package com.loopers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Spring Batch 애플리케이션 메인 클래스. + *

+ * 대량 데이터 집계 및 배치 처리를 위한 독립 실행형 애플리케이션입니다. + *

+ *

+ * 실행 방법: + *

+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productMetricsAggregationJob \
+ *   targetDate=20241215
+ * 
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@SpringBootApplication(scanBasePackages = "com.loopers") +@EnableJpaRepositories(basePackages = "com.loopers.infrastructure") +@EntityScan(basePackages = "com.loopers.domain") +public class BatchApplication { + + public static void main(String[] args) { + System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args))); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..953aae115 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,134 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

+ * Spring Batch에서 집계 및 조회를 위한 메트릭 엔티티입니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
  • + *
  • Spring Batch를 통한 대량 데이터 처리
  • + *
+ *

+ *

+ * 모듈별 독립성: + *

    + *
  • commerce-batch 전용 엔티티 (독립적 진화 가능)
  • + *
  • commerce-streamer와는 별도로 관리되어 모듈별 커스터마이징 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

+ * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

+ * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..aa831ba5a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,86 @@ +package com.loopers.domain.metrics; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

+ *

+ * 도메인 분리 근거: + *

    + *
  • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
  • + *
  • Product 도메인의 핵심 비즈니스 로직과는 분리
  • + *
+ *

+ *

+ * 배치 전용 메서드: + *

    + *
  • Spring Batch에서 날짜 기반 조회를 위한 메서드 포함
  • + *
  • 대량 데이터 처리를 위한 페이징 조회 지원
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

+ * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) + * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ); + + /** + * Spring Batch의 RepositoryItemReader에서 사용하기 위한 JPA Repository를 반환합니다. + *

+ * RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로, + * 기술적 제약으로 인해 JPA Repository에 대한 접근을 제공합니다. + *

+ *

+ * 주의: 이 메서드는 Spring Batch의 기술적 요구사항으로 인해 제공됩니다. + * 일반적인 비즈니스 로직에서는 이 메서드를 사용하지 않고, + * 위의 도메인 메서드들을 사용해야 합니다. + *

+ * + * @return PagingAndSortingRepository를 구현한 JPA Repository + */ + @SuppressWarnings("rawtypes") + org.springframework.data.repository.PagingAndSortingRepository getJpaRepository(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..576eb158d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,166 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

+ * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

+ *

+ * Materialized View 설계: + *

    + *
  • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
  • + *
  • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
  • + *
  • TOP 100만 저장하여 조회 성능 최적화
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
  • + *
  • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
    + *
  • 주간: 해당 주의 월요일 (ISO 8601 기준)
  • + *
  • 월간: 해당 월의 1일
  • + *
+ */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRank 인스턴스를 생성합니다. + * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @param rank 랭킹 (1-100) + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public ProductRank( + PeriodType periodType, + LocalDate periodStartDate, + Long productId, + Integer rank, + Long likeCount, + Long salesCount, + Long viewCount + ) { + this.periodType = periodType; + this.periodStartDate = periodStartDate; + this.productId = productId; + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 랭킹 정보를 업데이트합니다. + * + * @param rank 새로운 랭킹 + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) { + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..f30679126 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

+ */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 저장합니다. + *

+ * 기존 데이터가 있으면 삭제 후 새로 저장합니다 (UPSERT 방식). + *

+ * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param ranks 저장할 랭킹 리스트 (TOP 100) + */ + void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks); + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); + + /** + * 특정 기간의 기존 랭킹 데이터를 삭제합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + */ + void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java new file mode 100644 index 000000000..97653efd6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java @@ -0,0 +1,141 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 랭킹 점수 집계 임시 엔티티. + *

+ * Step 1 (집계 로직 계산)에서 사용하는 임시 테이블입니다. + * product_id별로 점수를 집계하여 저장하며, 랭킹 번호는 저장하지 않습니다. + *

+ *

+ * 사용 목적: + *

    + *
  • Step 1에서 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
  • + *
  • Step 2에서 전체 데이터를 읽어서 TOP 100 선정 및 랭킹 번호 부여
  • + *
+ *

+ *

+ * 인덱스 전략: + *

    + *
  • product_id에 유니크 인덱스: 같은 product_id는 하나의 레코드만 존재 (UPSERT 방식)
  • + *
  • score에 인덱스: Step 2에서 정렬 시 성능 최적화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "tmp_product_rank_score", + indexes = { + @Index(name = "idx_product_id", columnList = "product_id", unique = true), + @Index(name = "idx_score", columnList = "score") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankScore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + /** + * 좋아요 수 (집계된 값) + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 (집계된 값) + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 (집계된 값) + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 종합 점수 + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ */ + @Column(name = "score", nullable = false) + private Double score; + + /** + * 메트릭 값을 설정합니다. + *

+ * Repository에서만 사용하는 내부 메서드입니다. + *

+ */ + public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRankScore 인스턴스를 생성합니다. + * + * @param productId 상품 ID + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @param score 종합 점수 + */ + public ProductRankScore( + Long productId, + Long likeCount, + Long salesCount, + Long viewCount, + Double score + ) { + this.productId = productId; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java new file mode 100644 index 000000000..149357a81 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java @@ -0,0 +1,68 @@ +package com.loopers.domain.rank; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRankScore 도메인 Repository 인터페이스. + *

+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

+ */ +public interface ProductRankScoreRepository { + + /** + * ProductRankScore를 저장합니다. + *

+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

+ * + * @param score 저장할 ProductRankScore + */ + void save(ProductRankScore score); + + /** + * 여러 ProductRankScore를 저장합니다. + *

+ * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

+ * + * @param scores 저장할 ProductRankScore 리스트 + */ + void saveAll(List scores); + + /** + * product_id로 ProductRankScore를 조회합니다. + * + * @param productId 상품 ID + * @return ProductRankScore (없으면 Optional.empty()) + */ + Optional findByProductId(Long productId); + + /** + * 모든 ProductRankScore를 점수 내림차순으로 조회합니다. + *

+ * Step 2에서 TOP 100 선정을 위해 사용합니다. + *

+ * + * @param limit 조회할 최대 개수 (기본: 전체) + * @return ProductRankScore 리스트 (점수 내림차순) + */ + List findAllOrderByScoreDesc(int limit); + + /** + * 모든 ProductRankScore를 조회합니다. + * + * @return ProductRankScore 리스트 + */ + List findAll(); + + /** + * 모든 ProductRankScore를 삭제합니다. + *

+ * Step 2 완료 후 임시 테이블을 정리하기 위해 사용합니다. + *

+ */ + void deleteAll(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java new file mode 100644 index 000000000..7d23b370a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemProcessor. + *

+ * 현재는 데이터를 그대로 전달하지만, 향후 집계 로직을 추가할 수 있습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Reader와 Writer 사이의 변환/필터링 로직을 위한 확장 포인트 제공
  • + *
  • 향후 주간/월간 집계를 위한 데이터 변환 로직 추가 가능
  • + *
  • 비즈니스 로직 검증 및 필터링 수행 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemProcessor implements ItemProcessor { + + /** + * ProductMetrics를 처리합니다. + *

+ * 현재는 데이터를 그대로 전달하지만, 필요시 변환/필터링 로직을 추가할 수 있습니다. + *

+ * + * @param item 처리할 ProductMetrics + * @return 처리된 ProductMetrics (null 반환 시 해당 항목은 Writer로 전달되지 않음) + */ + @Override + public ProductMetrics process(ProductMetrics item) throws Exception { + // 현재는 데이터를 그대로 전달 + // 향후 집계 로직이나 데이터 변환이 필요하면 여기에 추가 + return item; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java new file mode 100644 index 000000000..b7f420b87 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductMetrics를 읽기 위한 Spring Batch ItemReader Factory. + *

+ * Chunk-Oriented Processing을 위해 JPA Repository 기반 Reader를 생성합니다. + * 특정 날짜의 product_metrics 데이터를 페이징하여 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
  • + *
  • 날짜 파라미터를 받아 해당 날짜의 데이터만 조회
  • + *
  • product_id 기준 정렬로 일관된 읽기 순서 보장
  • + *
+ *

+ *

+ * DIP 준수: + *

    + *
  • 도메인 레이어의 ProductMetricsRepository 인터페이스를 사용
  • + *
  • Spring Batch의 기술적 제약으로 인해 getJpaRepository()를 통해 JPA Repository 접근
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsItemReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + *

+ * Job 파라미터에서 날짜를 받아 해당 날짜의 데이터만 조회합니다. + *

+ * + * @param targetDate 조회할 날짜 (yyyyMMdd 형식) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createReader(String targetDate) { + // 날짜 파라미터 파싱 + LocalDate date = parseDate(targetDate); + LocalDateTime startDateTime = date.atStartOfDay(); + LocalDateTime endDateTime = date.atTime(LocalTime.MAX); + + log.info("ProductMetrics Reader 초기화: targetDate={}, startDateTime={}, endDateTime={}", + date, startDateTime, endDateTime); + + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name("productMetricsReader") + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + *

+ * yyyyMMdd 형식의 문자열을 파싱하며, 파싱 실패 시 오늘 날짜를 반환합니다. + *

+ * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java new file mode 100644 index 000000000..89364f52e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemWriter. + *

+ * 현재는 로깅만 수행하지만, 향후 Materialized View에 저장하는 로직을 추가할 수 있습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 데이터를 처리하여 대량 데이터 처리 성능 최적화
  • + *
  • 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정
  • + *
  • 트랜잭션 단위는 Chunk 단위로 관리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemWriter implements ItemWriter { + + /** + * ProductMetrics Chunk를 처리합니다. + *

+ * 현재는 로깅만 수행하며, 향후 Materialized View에 저장하는 로직을 추가할 예정입니다. + *

+ * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + log.info("ProductMetrics Chunk 처리 시작: itemCount={}", items.size()); + + // 현재는 로깅만 수행 + // 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정 + for (ProductMetrics item : items) { + log.debug("ProductMetrics 처리: productId={}, likeCount={}, salesCount={}, viewCount={}, updatedAt={}", + item.getProductId(), item.getLikeCount(), item.getSalesCount(), + item.getViewCount(), item.getUpdatedAt()); + } + + log.info("ProductMetrics Chunk 처리 완료: itemCount={}", items.size()); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java new file mode 100644 index 000000000..1c874b3b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +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.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * ProductMetrics 집계를 위한 Spring Batch Job Configuration. + *

+ * Chunk-Oriented Processing 방식을 사용하여 대량의 product_metrics 데이터를 처리합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
  • + *
  • Job 파라미터 기반 실행: 날짜를 파라미터로 받아 특정 날짜의 데이터만 처리
  • + *
  • 확장성: 향후 주간/월간 집계를 위한 구조 준비
  • + *
  • 재시작 가능: 실패 시 이전 Chunk부터 재시작 가능
  • + *
+ *

+ *

+ * Chunk 크기 선택 근거: + *

    + *
  • 100개: 메모리 사용량과 성능의 균형
  • + *
  • 너무 작으면: 트랜잭션 오버헤드 증가
  • + *
  • 너무 크면: 메모리 사용량 증가 및 롤백 범위 확대
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductMetricsJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsItemReader productMetricsItemReader; + private final ProductMetricsItemProcessor productMetricsItemProcessor; + private final ProductMetricsItemWriter productMetricsItemWriter; + + /** + * ProductMetrics 집계 Job을 생성합니다. + *

+ * Job 파라미터: + *

    + *
  • targetDate: 처리할 날짜 (yyyyMMdd 형식, 예: "20241215")
  • + *
+ *

+ *

+ * 실행 예시: + *

+     * java -jar commerce-batch.jar --spring.batch.job.names=productMetricsAggregationJob targetDate=20241215
+     * 
+ *

+ * + * @return ProductMetrics 집계 Job + */ + @Bean + public Job productMetricsAggregationJob(Step productMetricsAggregationStep) { + return new JobBuilder("productMetricsAggregationJob", jobRepository) + .start(productMetricsAggregationStep) + .build(); + } + + /** + * ProductMetrics 집계 Step을 생성합니다. + *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: 특정 날짜의 product_metrics를 페이징하여 읽기
  2. + *
  3. Processor: 데이터 변환/필터링 (현재는 pass-through)
  4. + *
  5. Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)
  6. + *
+ *

+ * + * @param productMetricsReader ProductMetrics Reader (StepScope Bean) + * @param productMetricsProcessor ProductMetrics Processor + * @param productMetricsWriter ProductMetrics Writer + * @return ProductMetrics 집계 Step + */ + @Bean + public Step productMetricsAggregationStep( + ItemReader productMetricsReader, + ItemProcessor productMetricsProcessor, + ItemWriter productMetricsWriter + ) { + return new StepBuilder("productMetricsAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productMetricsReader) // StepScope Bean은 Step 실행 시점에 자동 주입됨 + .processor(productMetricsProcessor) + .writer(productMetricsWriter) + .build(); + } + + /** + * ProductMetrics Reader를 생성합니다. + *

+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

+ * + * @param targetDate 조회할 날짜 (Job 파라미터에서 주입) + * @return ProductMetrics Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + return productMetricsItemReader.createReader(targetDate); + } + + /** + * ProductMetrics Processor를 주입받습니다. + * + * @return ProductMetrics Processor + */ + @Bean + public ItemProcessor productMetricsProcessor() { + return productMetricsItemProcessor; + } + + /** + * ProductMetrics Writer를 주입받습니다. + * + * @return ProductMetrics Writer + */ + @Bean + public ItemWriter productMetricsWriter() { + return productMetricsItemWriter; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java new file mode 100644 index 000000000..2cf591cef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * ProductRank 집계를 위한 Processor. + *

+ * 기간 정보를 관리하고 Writer에서 사용할 수 있도록 제공합니다. + * 실제 집계는 Writer에서 Chunk 단위로 수행됩니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductRankAggregationProcessor { + + private ProductRank.PeriodType periodType; + private LocalDate periodStartDate; + + /** + * 기간 정보를 설정합니다. + *

+ * Job 파라미터에서 주입받아 설정합니다. + *

+ * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param targetDate 기준 날짜 + */ + public void setPeriod(ProductRank.PeriodType periodType, LocalDate targetDate) { + this.periodType = periodType; + + if (periodType == ProductRank.PeriodType.WEEKLY) { + // 주간 시작일: 해당 주의 월요일 + this.periodStartDate = targetDate.with(java.time.DayOfWeek.MONDAY); + } else if (periodType == ProductRank.PeriodType.MONTHLY) { + // 월간 시작일: 해당 월의 1일 + this.periodStartDate = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + } + } + + /** + * 기간 타입을 반환합니다. + * + * @return 기간 타입 + */ + public ProductRank.PeriodType getPeriodType() { + return periodType; + } + + /** + * 기간 시작일을 반환합니다. + * + * @return 기간 시작일 + */ + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java new file mode 100644 index 000000000..449cb18d2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductRank 집계를 위한 Spring Batch ItemReader Factory. + *

+ * 주간/월간 집계를 위해 특정 기간의 모든 ProductMetrics를 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터 조회
  • + *
  • 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터 조회
  • + *
  • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankAggregationReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 주간 집계를 위한 Reader를 생성합니다. + *

+ * 해당 주의 월요일부터 일요일까지의 ProductMetrics를 조회합니다. + *

+ * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createWeeklyReader(LocalDate targetDate) { + // 주간 시작일 계산 (월요일) + LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); + LocalDateTime startDateTime = weekStart.atStartOfDay(); + + // 주간 종료일 계산 (다음 주 월요일 00:00:00) + LocalDate weekEnd = weekStart.plusWeeks(1); + LocalDateTime endDateTime = weekEnd.atStartOfDay(); + + log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", + targetDate, weekStart, weekEnd); + + return createReader(startDateTime, endDateTime, "weeklyReader"); + } + + /** + * 월간 집계를 위한 Reader를 생성합니다. + *

+ * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다. + *

+ * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createMonthlyReader(LocalDate targetDate) { + // 월간 시작일 계산 (1일) + LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + LocalDateTime startDateTime = monthStart.atStartOfDay(); + + // 월간 종료일 계산 (다음 달 1일 00:00:00) + LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth()); + LocalDateTime endDateTime = monthEnd.atStartOfDay(); + + log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", + targetDate, monthStart, monthEnd); + + return createReader(startDateTime, endDateTime, "monthlyReader"); + } + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + * + * @param startDateTime 조회 시작 시각 + * @param endDateTime 조회 종료 시각 + * @param readerName Reader 이름 + * @return RepositoryItemReader 인스턴스 + */ + private RepositoryItemReader createReader( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + String readerName + ) { + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name(readerName) + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java new file mode 100644 index 000000000..cafcbc4cc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** + * ProductRankScore를 ProductRank로 변환하는 Processor. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore를 읽어서 랭킹 번호를 부여하고 ProductRank로 변환합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • ProductRankScore에 랭킹 번호 부여 (1부터 시작)
  • + *
  • TOP 100만 선정 (나머지는 null 반환하여 필터링)
  • + *
  • ProductRank로 변환
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationProcessor implements ItemProcessor { + + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final ThreadLocal currentRank = ThreadLocal.withInitial(() -> 0); + private static final int TOP_RANK_LIMIT = 100; + + /** + * ProductRankScore를 ProductRank로 변환합니다. + *

+ * 랭킹 번호를 부여하고, TOP 100에 포함되는 경우에만 ProductRank를 반환합니다. + *

+ * + * @param score ProductRankScore + * @return ProductRank (TOP 100에 포함되는 경우), null (그 외) + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public ProductRank process(ProductRankScore score) throws Exception { + int rank = currentRank.get() + 1; + currentRank.set(rank); + + // TOP 100에 포함되지 않으면 null 반환 (필터링) + if (rank > TOP_RANK_LIMIT) { + return null; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return null; + } + + // ProductRank 생성 (랭킹 번호 부여) + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + score.getProductId(), + rank, // 랭킹 번호 (1부터 시작) + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount() + ); + + // Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시) + if (rank == TOP_RANK_LIMIT) { + currentRank.remove(); + } + + return productRank; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java new file mode 100644 index 000000000..4b997f66c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.stereotype.Component; + +import java.util.Iterator; +import java.util.List; + +/** + * ProductRankScore를 읽는 Reader. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore 테이블에서 점수 내림차순으로 모든 데이터를 읽습니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Step 1에서 집계된 모든 ProductRankScore를 읽기
  • + *
  • 점수 내림차순으로 정렬된 데이터를 제공
  • + *
  • TOP 100 선정을 위해 전체 데이터를 읽어야 함
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationReader implements ItemReader { + + private final ProductRankScoreRepository productRankScoreRepository; + private Iterator scoreIterator; + private boolean initialized = false; + + /** + * ProductRankScore를 읽습니다. + *

+ * 첫 호출 시 모든 데이터를 조회하고, 이후 Iterator를 통해 하나씩 반환합니다. + *

+ * + * @return ProductRankScore (더 이상 없으면 null) + * @throws UnexpectedInputException 예상치 못한 입력 오류 + * @throws ParseException 파싱 오류 + * @throws NonTransientResourceException 일시적이지 않은 리소스 오류 + */ + @Override + public ProductRankScore read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException { + if (!initialized) { + // 첫 호출 시 모든 데이터를 점수 내림차순으로 조회 + List scores = productRankScoreRepository.findAllOrderByScoreDesc(0); + this.scoreIterator = scores.iterator(); + this.initialized = true; + + log.info("ProductRankScore 조회 완료: totalCount={}", scores.size()); + } + + if (scoreIterator.hasNext()) { + return scoreIterator.next(); + } + + return null; // 더 이상 읽을 데이터가 없음 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java new file mode 100644 index 000000000..71fd8ea5c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java @@ -0,0 +1,82 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * ProductRank를 Materialized View에 저장하는 Writer. + *

+ * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * 랭킹 번호가 부여된 ProductRank를 Materialized View에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 받은 ProductRank를 수집하고 저장
  • + *
  • 각 Chunk마다 전체 ProductRank를 저장 (saveRanks가 delete + insert를 수행)
  • + *
  • 기존 데이터 삭제 후 새 데이터 저장 (delete + insert 방식)
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationWriter implements ItemWriter { + + private final ProductRankRepository productRankRepository; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final List allRanks = new java.util.ArrayList<>(); + + /** + * ProductRank Chunk를 수집하고 저장합니다. + *

+ * 모든 Chunk를 메모리에 모아두고, 각 Chunk마다 전체를 저장합니다. + * saveRanks가 delete + insert를 수행하므로, 각 Chunk마다 전체를 저장해도 문제없습니다. + *

+ * + * @param chunk 처리할 ProductRank Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems() + .stream() + .filter(item -> item != null) // null 필터링 (TOP 100에 포함되지 않은 항목) + .collect(Collectors.toList()); + + if (items.isEmpty()) { + return; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return; + } + + // 모든 Chunk를 수집 + allRanks.addAll(items); + log.debug("ProductRank Chunk 수집: count={}, total={}", items.size(), allRanks.size()); + + // 각 Chunk마다 전체를 저장 (saveRanks가 delete + insert를 수행하므로 문제없음) + log.info("ProductRank 저장: periodType={}, periodStartDate={}, total={}", + periodType, periodStartDate, allRanks.size()); + productRankRepository.saveRanks(periodType, periodStartDate, allRanks); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java new file mode 100644 index 000000000..a8c06a0e5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -0,0 +1,257 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +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.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +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 java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * ProductRank 집계를 위한 Spring Batch Job Configuration. + *

+ * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Step 1 (집계 로직 계산): 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
  • + *
  • Step 2 (랭킹 로직 실행): 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여
  • + *
  • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
  • + *
  • Materialized View 저장: 조회 성능 최적화를 위한 TOP 100 랭킹 저장
  • + *
+ *

+ *

+ * Job 파라미터: + *

    + *
  • periodType: 기간 타입 (WEEKLY 또는 MONTHLY)
  • + *
  • targetDate: 기준 날짜 (yyyyMMdd 형식, 예: "20241215")
  • + *
+ *

+ *

+ * 실행 예시: + *

+ * // 주간 집계
+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productRankAggregationJob \
+ *   periodType=WEEKLY targetDate=20241215
+ *
+ * // 월간 집계
+ * java -jar commerce-batch.jar \
+ *   --spring.batch.job.names=productRankAggregationJob \
+ *   periodType=MONTHLY targetDate=20241215
+ * 
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductRankJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductRankAggregationReader productRankAggregationReader; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final ProductRankScoreAggregationWriter productRankScoreAggregationWriter; + private final ProductRankCalculationReader productRankCalculationReader; + private final ProductRankCalculationProcessor productRankCalculationProcessor; + private final ProductRankCalculationWriter productRankCalculationWriter; + + /** + * ProductRank 집계 Job을 생성합니다. + *

+ * 2-Step 구조: + *

    + *
  1. Step 1: 집계 로직 계산 (점수 집계)
  2. + *
  3. Step 2: 랭킹 로직 실행 (TOP 100 선정 및 랭킹 번호 부여)
  4. + *
+ *

+ * + * @param scoreAggregationStep Step 1: 집계 로직 계산 Step + * @param rankingCalculationStep Step 2: 랭킹 로직 실행 Step + * @return ProductRank 집계 Job + */ + @Bean + public Job productRankAggregationJob( + Step scoreAggregationStep, + Step rankingCalculationStep + ) { + return new JobBuilder("productRankAggregationJob", jobRepository) + .start(scoreAggregationStep) // Step 1 먼저 실행 + .next(rankingCalculationStep) // Step 1 완료 후 Step 2 실행 + .build(); + } + + /** + * Step 1: 집계 로직 계산 Step을 생성합니다. + *

+ * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다. + *

+ *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: 특정 기간의 product_metrics를 페이징하여 읽기
  2. + *
  3. Processor: Pass-through (필터링 필요 시 추가 가능)
  4. + *
  5. Writer: product_id별로 점수 집계하여 ProductRankScore 테이블에 저장
  6. + *
+ *

+ * + * @param productRankReader ProductRank Reader (StepScope Bean) + * @param productRankScoreWriter ProductRankScore Writer + * @return 집계 로직 계산 Step + */ + @Bean + public Step scoreAggregationStep( + ItemReader productRankReader, + ItemWriter productRankScoreWriter + ) { + return new StepBuilder("scoreAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankReader) + .processor(item -> item) // Pass-through + .writer(productRankScoreWriter) + .build(); + } + + /** + * Step 2: 랭킹 로직 실행 Step을 생성합니다. + *

+ * 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여하여 Materialized View에 저장합니다. + *

+ *

+ * Chunk-Oriented Processing을 사용하여: + *

    + *
  1. Reader: ProductRankScore 테이블에서 모든 데이터를 점수 내림차순으로 읽기
  2. + *
  3. Processor: TOP 100 선정 및 랭킹 번호 부여
  4. + *
  5. Writer: ProductRank를 수집하고 저장
  6. + *
+ *

+ * + * @param productRankScoreReader ProductRankScore Reader + * @param productRankCalculationProcessor ProductRank 계산 Processor + * @param productRankCalculationWriter ProductRank 계산 Writer + * @return 랭킹 로직 실행 Step + */ + @Bean + public Step rankingCalculationStep( + ItemReader productRankScoreReader, + ItemProcessor productRankCalculationProcessor, + ItemWriter productRankCalculationWriter + ) { + return new StepBuilder("rankingCalculationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankScoreReader) + .processor(productRankCalculationProcessor) + .writer(productRankCalculationWriter) + .build(); + } + + /** + * ProductRank Reader를 생성합니다. + *

+ * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

+ * + * @param periodType 기간 타입 (Job 파라미터에서 주입) + * @param targetDate 기준 날짜 (Job 파라미터에서 주입) + * @return ProductRank Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productRankReader( + @Value("#{jobParameters['periodType']}") String periodType, + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate date = parseDate(targetDate); + ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase()); + + // Processor에 기간 정보 설정 + productRankAggregationProcessor.setPeriod(period, date); + + if (period == ProductRank.PeriodType.WEEKLY) { + return productRankAggregationReader.createWeeklyReader(date); + } else { + return productRankAggregationReader.createMonthlyReader(date); + } + } + + /** + * Step 1용 ProductRankScore Writer를 주입받습니다. + * + * @return ProductRankScore Writer + */ + @Bean + public ItemWriter productRankScoreWriter() { + return productRankScoreAggregationWriter; + } + + /** + * Step 2용 ProductRankScore Reader를 주입받습니다. + * + * @return ProductRankScore Reader + */ + @Bean + public ItemReader productRankScoreReader() { + return productRankCalculationReader; + } + + /** + * Step 2용 ProductRank 계산 Processor를 주입받습니다. + * + * @return ProductRank 계산 Processor + */ + @Bean + public ItemProcessor productRankCalculationProcessor() { + return productRankCalculationProcessor; + } + + /** + * Step 2용 ProductRank 계산 Writer를 주입받습니다. + * + * @return ProductRank 계산 Writer + */ + @Bean + public ItemWriter productRankCalculationWriter() { + return productRankCalculationWriter; + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java new file mode 100644 index 000000000..f1e3d6404 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java @@ -0,0 +1,170 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * ProductRankScore 집계를 위한 Writer. + *

+ * Step 1 (집계 로직 계산 Step)에서 사용합니다. + * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고, + * ProductRankScore 임시 테이블에 저장합니다. + *

+ *

+ * 구현 의도: + *

    + *
  • Chunk 단위로 받은 ProductMetrics를 product_id별로 집계
  • + *
  • 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
  • + *
  • ProductRankScore 테이블에 저장 (랭킹 번호 없이)
  • + *
  • 같은 product_id가 여러 Chunk에 걸쳐 있을 경우 UPSERT 방식으로 누적
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankScoreAggregationWriter implements ItemWriter { + + private final ProductRankScoreRepository productRankScoreRepository; + + /** + * ProductMetrics Chunk를 집계하여 ProductRankScore 테이블에 저장합니다. + *

+ * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고 저장합니다. + * 같은 product_id가 여러 Chunk에 걸쳐 있을 경우, 기존 데이터를 조회하여 누적한 후 저장합니다. + *

+ * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + if (items.isEmpty()) { + log.warn("ProductMetrics Chunk가 비어있습니다."); + return; + } + + log.debug("ProductRankScore Chunk 처리 시작: itemCount={}", items.size()); + + // 같은 product_id를 가진 메트릭을 합산 (Chunk 내에서) + Map chunkAggregatedMap = items.stream() + .collect(Collectors.groupingBy( + ProductMetrics::getProductId, + Collectors.reducing( + new AggregatedMetrics(0L, 0L, 0L), + metrics -> new AggregatedMetrics( + metrics.getLikeCount(), + metrics.getSalesCount(), + metrics.getViewCount() + ), + (a, b) -> new AggregatedMetrics( + a.getLikeCount() + b.getLikeCount(), + a.getSalesCount() + b.getSalesCount(), + a.getViewCount() + b.getViewCount() + ) + ) + )); + + // 기존 데이터와 누적하여 ProductRankScore 생성 + List scores = chunkAggregatedMap.entrySet().stream() + .map(entry -> { + Long productId = entry.getKey(); + AggregatedMetrics chunkAggregated = entry.getValue(); + + // 기존 데이터 조회 + java.util.Optional existing = productRankScoreRepository.findByProductId(productId); + + // 기존 데이터와 누적 + Long totalLikeCount = chunkAggregated.getLikeCount(); + Long totalSalesCount = chunkAggregated.getSalesCount(); + Long totalViewCount = chunkAggregated.getViewCount(); + + if (existing.isPresent()) { + ProductRankScore existingScore = existing.get(); + totalLikeCount += existingScore.getLikeCount(); + totalSalesCount += existingScore.getSalesCount(); + totalViewCount += existingScore.getViewCount(); + } + + // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2) + double score = calculateScore(totalLikeCount, totalSalesCount, totalViewCount); + + return new ProductRankScore( + productId, + totalLikeCount, + totalSalesCount, + totalViewCount, + score + ); + }) + .collect(Collectors.toList()); + + // 저장 (기존 데이터가 있으면 덮어쓰기) + productRankScoreRepository.saveAll(scores); + + log.debug("ProductRankScore 저장 완료: count={}", scores.size()); + } + + /** + * 종합 점수를 계산합니다. + *

+ * 가중치: + *

    + *
  • 좋아요: 0.3
  • + *
  • 판매량: 0.5
  • + *
  • 조회수: 0.2
  • + *
+ *

+ * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + } + + /** + * 집계된 메트릭을 담는 내부 클래스. + */ + private static class AggregatedMetrics { + private final Long likeCount; + private final Long salesCount; + private final Long viewCount; + + public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + } + + public Long getLikeCount() { + return likeCount; + } + + public Long getSalesCount() { + return salesCount; + } + + public Long getViewCount() { + return viewCount; + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..e76dd736f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

+ * 상품 메트릭 집계 데이터를 관리합니다. + * commerce-batch 전용 Repository입니다. + *

+ *

+ * 모듈별 독립성: + *

    + *
  • commerce-batch의 필요에 맞게 커스터마이징된 Repository
  • + *
  • Spring Batch에서 날짜 기반 조회에 최적화
  • + *
+ *

+ */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

+ * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

+ * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) + * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + @Query("SELECT pm FROM ProductMetrics pm " + + "WHERE pm.updatedAt >= :startDateTime AND pm.updatedAt < :endDateTime " + + "ORDER BY pm.productId") + Page findByUpdatedAtBetween( + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable + ); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..70b775e30 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

+ *

+ * 배치 전용 구현: + *

    + *
  • Spring Batch에서 날짜 기반 조회에 최적화
  • + *
  • 대량 데이터 처리를 위한 페이징 조회 지원
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ) { + return productMetricsJpaRepository.findByUpdatedAtBetween(startDateTime, endDateTime, pageable); + } + + /** + * {@inheritDoc} + */ + @Override + @SuppressWarnings("rawtypes") + public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() { + return productMetricsJpaRepository; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..d50aa8991 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,95 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

+ * Materialized View에 저장된 상품 랭킹 데이터를 관리합니다. + *

+ */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks) { + // 기존 데이터 삭제 + deleteByPeriod(periodType, periodStartDate); + + // 새 데이터 저장 + for (ProductRank rank : ranks) { + entityManager.persist(rank); + } + + log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}", + periodType, periodStartDate, ranks.size()); + } + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + @Transactional + public void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate) { + String jpql = "DELETE FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate"; + + int deletedCount = entityManager.createQuery(jpql) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .executeUpdate(); + + log.debug("ProductRank 삭제 완료: periodType={}, periodStartDate={}, deletedCount={}", + periodType, periodStartDate, deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java new file mode 100644 index 000000000..b210d9ce2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java @@ -0,0 +1,100 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRankScore Repository 구현체. + *

+ * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

+ */ +@Slf4j +@Repository +public class ProductRankScoreRepositoryImpl implements ProductRankScoreRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void save(ProductRankScore score) { + Optional existing = findByProductId(score.getProductId()); + + if (existing.isPresent()) { + // 기존 레코드가 있으면 덮어쓰기 (Writer에서 이미 누적된 값을 전달받음) + ProductRankScore existingScore = existing.get(); + existingScore.setMetrics( + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount(), + score.getScore() + ); + entityManager.merge(existingScore); + log.debug("ProductRankScore 업데이트: productId={}", score.getProductId()); + } else { + // 없으면 새로 생성 + entityManager.persist(score); + log.debug("ProductRankScore 생성: productId={}", score.getProductId()); + } + } + + @Override + @Transactional + public void saveAll(List scores) { + for (ProductRankScore score : scores) { + save(score); + } + log.info("ProductRankScore 일괄 저장 완료: count={}", scores.size()); + } + + @Override + public Optional findByProductId(Long productId) { + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId = :productId"; + + try { + ProductRankScore score = entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(score); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + public List findAllOrderByScoreDesc(int limit) { + String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC"; + + jakarta.persistence.TypedQuery query = + entityManager.createQuery(jpql, ProductRankScore.class); + if (limit > 0) { + query.setMaxResults(limit); + } + + return query.getResultList(); + } + + @Override + public List findAll() { + String jpql = "SELECT prs FROM ProductRankScore prs"; + return entityManager.createQuery(jpql, ProductRankScore.class).getResultList(); + } + + @Override + @Transactional + public void deleteAll() { + String jpql = "DELETE FROM ProductRankScore"; + int deletedCount = entityManager.createQuery(jpql).executeUpdate(); + log.info("ProductRankScore 전체 삭제 완료: deletedCount={}", deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..8c66d71dc --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,43 @@ +spring: + main: + web-application-type: none # 배치 전용이므로 웹 서버 불필요 + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + jdbc: + initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 + job: + enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..133932ae4 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,217 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetrics 도메인 엔티티 테스트. + *

+ * commerce-batch 모듈의 ProductMetrics 엔티티에 대한 단위 테스트입니다. + *

+ */ +class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장)") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 크면 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenEventVersionIsGreater() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + Long eventVersion = 2L; + + // act + boolean result = metrics.shouldUpdate(eventVersion); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 작거나 같으면 업데이트하지 않아야 한다고 판단한다") + @Test + void shouldNotUpdate_whenEventVersionIsLessOrEqual() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + metrics.incrementLikeCount(); // version = 2 + + // act & assert + assertThat(metrics.shouldUpdate(1L)).isFalse(); // 이벤트 버전이 더 작음 + assertThat(metrics.shouldUpdate(2L)).isFalse(); // 이벤트 버전이 같음 + } + + @DisplayName("이벤트 버전이 null이면 업데이트해야 한다고 판단한다 (하위 호환성)") + @Test + void shouldUpdate_whenEventVersionIsNull() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + + // act + boolean result = metrics.shouldUpdate(null); + + // assert + assertThat(result).isTrue(); // 하위 호환성을 위해 null이면 업데이트 + } + + @DisplayName("초기 버전(0)인 메트릭은 모든 이벤트 버전에 대해 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenMetricsVersionIsZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getVersion()).isEqualTo(0L); + + // act & assert + assertThat(metrics.shouldUpdate(0L)).isFalse(); // 같으면 업데이트 안 함 + assertThat(metrics.shouldUpdate(1L)).isTrue(); // 더 크면 업데이트 + assertThat(metrics.shouldUpdate(100L)).isTrue(); // 더 크면 업데이트 + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java new file mode 100644 index 000000000..72d0c592f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java @@ -0,0 +1,235 @@ +package com.loopers.domain.rank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRank 도메인 엔티티 테스트. + *

+ * commerce-batch 모듈의 ProductRank 엔티티에 대한 단위 테스트입니다. + *

+ */ +class ProductRankTest { + + @DisplayName("ProductRank는 모든 필수 정보로 생성된다") + @Test + void createsProductRankWithAllFields() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); // 월요일 + Long productId = 1L; + Integer rank = 1; + Long likeCount = 100L; + Long salesCount = 500L; + Long viewCount = 1000L; + + // act + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + productId, + rank, + likeCount, + salesCount, + viewCount + ); + + // assert + assertThat(productRank.getPeriodType()).isEqualTo(periodType); + assertThat(productRank.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(productRank.getProductId()).isEqualTo(productId); + assertThat(productRank.getRank()).isEqualTo(rank); + assertThat(productRank.getLikeCount()).isEqualTo(likeCount); + assertThat(productRank.getSalesCount()).isEqualTo(salesCount); + assertThat(productRank.getViewCount()).isEqualTo(viewCount); + assertThat(productRank.getCreatedAt()).isNotNull(); + assertThat(productRank.getUpdatedAt()).isNotNull(); + } + + @DisplayName("ProductRank 생성 시 createdAt과 updatedAt이 현재 시간으로 설정된다") + @Test + void setsCreatedAtAndUpdatedAtOnCreation() throws InterruptedException { + // arrange + LocalDateTime beforeCreation = LocalDateTime.now(); + Thread.sleep(1); + + // act + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + + Thread.sleep(1); + LocalDateTime afterCreation = LocalDateTime.now(); + + // assert + assertThat(productRank.getCreatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + assertThat(productRank.getUpdatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + } + + @DisplayName("주간 랭킹을 생성할 수 있다") + @Test + void createsWeeklyRank() { + // arrange + LocalDate weekStart = LocalDate.of(2024, 12, 9); // 월요일 + + // act + ProductRank weeklyRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + weekStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(weeklyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(weeklyRank.getPeriodStartDate()).isEqualTo(weekStart); + } + + @DisplayName("월간 랭킹을 생성할 수 있다") + @Test + void createsMonthlyRank() { + // arrange + LocalDate monthStart = LocalDate.of(2024, 12, 1); // 월의 1일 + + // act + ProductRank monthlyRank = new ProductRank( + ProductRank.PeriodType.MONTHLY, + monthStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(monthlyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(monthlyRank.getPeriodStartDate()).isEqualTo(monthStart); + } + + @DisplayName("랭킹 정보를 업데이트할 수 있다") + @Test + void canUpdateRank() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + Integer newRank = 2; + Long newLikeCount = 200L; + Long newSalesCount = 600L; + Long newViewCount = 1100L; + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + productRank.updateRank(newRank, newLikeCount, newSalesCount, newViewCount); + + // assert + assertThat(productRank.getRank()).isEqualTo(newRank); + assertThat(productRank.getLikeCount()).isEqualTo(newLikeCount); + assertThat(productRank.getSalesCount()).isEqualTo(newSalesCount); + assertThat(productRank.getViewCount()).isEqualTo(newViewCount); + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("랭킹 업데이트 시 updatedAt이 갱신된다") + @Test + void updatesUpdatedAtWhenRankIsUpdated() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); + productRank.updateRank(2, 200L, 600L, 1100L); + + // assert + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("PeriodType enum이 올바르게 정의되어 있다") + @Test + void periodTypeEnumIsCorrectlyDefined() { + // assert + assertThat(ProductRank.PeriodType.WEEKLY).isNotNull(); + assertThat(ProductRank.PeriodType.MONTHLY).isNotNull(); + assertThat(ProductRank.PeriodType.values()).hasSize(2); + } + + @DisplayName("TOP 100 랭킹을 생성할 수 있다") + @Test + void createsTop100Rank() { + // arrange + Integer topRank = 100; + + // act + ProductRank top100Rank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 100L, + topRank, + 1L, + 1L, + 1L + ); + + // assert + assertThat(top100Rank.getRank()).isEqualTo(topRank); + assertThat(top100Rank.getRank()).isLessThanOrEqualTo(100); + } + + @DisplayName("랭킹 1위를 생성할 수 있다") + @Test + void createsFirstRank() { + // arrange + Integer firstRank = 1; + + // act + ProductRank firstPlaceRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + firstRank, + 1000L, + 5000L, + 10000L + ); + + // assert + assertThat(firstPlaceRank.getRank()).isEqualTo(firstRank); + assertThat(firstPlaceRank.getRank()).isGreaterThanOrEqualTo(1); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java new file mode 100644 index 000000000..23869009a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsItemProcessor 테스트. + */ +class ProductMetricsItemProcessorTest { + + private final ProductMetricsItemProcessor processor = new ProductMetricsItemProcessor(); + + @DisplayName("ProductMetrics를 그대로 전달한다 (pass-through)") + @Test + void processesItem_andReturnsSameItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + item.incrementSalesCount(10); + item.incrementViewCount(); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); // 동일한 객체 반환 + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getLikeCount()).isEqualTo(1L); + assertThat(result.getSalesCount()).isEqualTo(10L); + assertThat(result.getViewCount()).isEqualTo(1L); + } + + @DisplayName("null이 아닌 모든 ProductMetrics를 처리한다") + @Test + void processesNonNullItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(100L); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isNotNull(); + assertThat(result).isSameAs(item); + } + + @DisplayName("여러 번 처리해도 동일한 결과를 반환한다") + @Test + void processesItemMultipleTimes_returnsSameResult() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + + // act + ProductMetrics result1 = processor.process(item); + ProductMetrics result2 = processor.process(item); + ProductMetrics result3 = processor.process(item); + + // assert + assertThat(result1).isSameAs(item); + assertThat(result2).isSameAs(item); + assertThat(result3).isSameAs(item); + } + + @DisplayName("초기값을 가진 ProductMetrics도 처리한다") + @Test + void processesItemWithInitialValues() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + // 초기값: 모든 카운트가 0 + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); + assertThat(result.getLikeCount()).isEqualTo(0L); + assertThat(result.getSalesCount()).isEqualTo(0L); + assertThat(result.getViewCount()).isEqualTo(0L); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java new file mode 100644 index 000000000..4a3a75f93 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java @@ -0,0 +1,134 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductMetricsItemReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsItemReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("올바른 날짜 형식으로 Reader를 생성할 수 있다") + @Test + void createsReader_withValidDate() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("productMetricsReader"); + } + + @DisplayName("날짜 파라미터가 null이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withNullDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(null); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터가 빈 문자열이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withEmptyDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(""); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("잘못된 날짜 형식이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withInvalidDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader("invalid-date"); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터를 올바르게 파싱하여 날짜 범위를 설정한다") + @Test + void parsesDateCorrectly_andSetsDateTimeRange() { + // arrange + String targetDate = "20241215"; + LocalDate expectedDate = LocalDate.of(2024, 12, 15); + LocalDateTime expectedStart = expectedDate.atStartOfDay(); + LocalDateTime expectedEnd = expectedDate.atTime(LocalTime.MAX); + + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 날짜 파싱이 올바르게 되었는지 확인 (Reader 내부에서 사용되므로 간접적으로 검증) + // 실제 날짜 범위는 Repository 호출 시 사용되므로, Reader가 정상 생성되었으면 성공 + } + + @DisplayName("JPA Repository를 통해 Reader를 생성한다") + @Test + void createsReader_usingJpaRepository() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // getJpaRepository()가 호출되었는지 확인 + // (실제로는 RepositoryItemReader 내부에서 사용되므로 간접적으로 검증) + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java new file mode 100644 index 000000000..d0613096e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.item.Chunk; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * ProductMetricsItemWriter 테스트. + */ +class ProductMetricsItemWriterTest { + + private final ProductMetricsItemWriter writer = new ProductMetricsItemWriter(); + + @DisplayName("Chunk를 정상적으로 처리할 수 있다") + @Test + void writesChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(3); + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("빈 Chunk도 처리할 수 있다") + @Test + void writesEmptyChunk_successfully() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("큰 Chunk도 처리할 수 있다") + @Test + void writesLargeChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(100); // Chunk 크기와 동일 + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("다양한 메트릭 값을 가진 Chunk를 처리할 수 있다") + @Test + void writesChunk_withVariousMetrics() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(2L); + metrics2.incrementSalesCount(100); + items.add(metrics2); + + ProductMetrics metrics3 = new ProductMetrics(3L); + metrics3.incrementViewCount(); + metrics3.incrementViewCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("Chunk의 모든 항목을 처리한다") + @Test + void writesChunk_processesAllItems() throws Exception { + // arrange + int itemCount = 10; + List items = createProductMetricsList(itemCount); + Chunk chunk = new Chunk<>(items); + + // act + writer.write(chunk); + + // assert + // 현재는 로깅만 수행하므로 예외가 발생하지 않으면 성공 + // 향후 Materialized View 저장 로직 추가 시 추가 검증 필요 + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + /** + * 테스트용 ProductMetrics 리스트를 생성합니다. + * + * @param count 생성할 항목 수 + * @return ProductMetrics 리스트 + */ + private List createProductMetricsList(int count) { + List items = new ArrayList<>(); + for (long i = 1; i <= count; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + return items; + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java new file mode 100644 index 000000000..a87ec4585 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRankAggregationProcessor 테스트. + */ +class ProductRankAggregationProcessorTest { + + private final ProductRankAggregationProcessor processor = new ProductRankAggregationProcessor(); + + @DisplayName("주간 기간 정보를 설정할 수 있다") + @Test + void setsWeeklyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + } + + @DisplayName("월간 기간 정보를 설정할 수 있다") + @Test + void setsMonthlyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 월의 1일 + } + + @DisplayName("주간 기간 설정 시 해당 주의 월요일을 시작일로 계산한다") + @Test + void calculatesWeekStartAsMonday_whenSettingWeeklyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act & assert + processor.setPeriod(periodType, monday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, wednesday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, sunday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + } + + @DisplayName("월간 기간 설정 시 해당 월의 1일을 시작일로 계산한다") + @Test + void calculatesMonthStartAsFirstDay_whenSettingMonthlyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate expectedStart = LocalDate.of(2024, 12, 1); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act & assert + processor.setPeriod(periodType, firstDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, midDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, lastDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + } + + @DisplayName("기간 정보를 여러 번 설정할 수 있다") + @Test + void canSetPeriodMultipleTimes() { + // arrange + LocalDate firstDate = LocalDate.of(2024, 12, 15); + LocalDate secondDate = LocalDate.of(2024, 11, 20); + + // act + processor.setPeriod(ProductRank.PeriodType.WEEKLY, firstDate); + ProductRank.PeriodType firstType = processor.getPeriodType(); + LocalDate firstStart = processor.getPeriodStartDate(); + + processor.setPeriod(ProductRank.PeriodType.MONTHLY, secondDate); + ProductRank.PeriodType secondType = processor.getPeriodType(); + LocalDate secondStart = processor.getPeriodStartDate(); + + // assert + assertThat(firstType).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(firstStart).isEqualTo(LocalDate.of(2024, 12, 9)); + + assertThat(secondType).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(secondStart).isEqualTo(LocalDate.of(2024, 11, 1)); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java new file mode 100644 index 000000000..50e225b7e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java @@ -0,0 +1,152 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankAggregationReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankAggregationReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("주간 Reader를 생성할 수 있다") + @Test + void createsWeeklyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("weeklyReader"); + } + + @DisplayName("주간 Reader는 해당 주의 월요일부터 다음 주 월요일까지의 데이터를 조회한다") + @Test + void weeklyReaderQueriesFromMondayToNextMonday() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 주간 시작일은 해당 주의 월요일이어야 함 + // 2024-12-15(일) -> 2024-12-09(월)이 시작일 + } + + @DisplayName("월간 Reader를 생성할 수 있다") + @Test + void createsMonthlyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("monthlyReader"); + } + + @DisplayName("월간 Reader는 해당 월의 1일부터 다음 달 1일까지의 데이터를 조회한다") + @Test + void monthlyReaderQueriesFromFirstDayToNextMonth() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 월간 시작일은 해당 월의 1일이어야 함 + // 2024-12-15 -> 2024-12-01이 시작일 + } + + @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다") + @Test + void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act + RepositoryItemReader mondayReader = reader.createWeeklyReader(monday); + RepositoryItemReader wednesdayReader = reader.createWeeklyReader(wednesday); + RepositoryItemReader sundayReader = reader.createWeeklyReader(sunday); + + // assert + assertThat(mondayReader).isNotNull(); + assertThat(wednesdayReader).isNotNull(); + assertThat(sundayReader).isNotNull(); + // 모두 같은 주의 월요일부터 시작해야 함 + } + + @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다") + @Test + void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act + RepositoryItemReader firstDayReader = reader.createMonthlyReader(firstDay); + RepositoryItemReader midDayReader = reader.createMonthlyReader(midDay); + RepositoryItemReader lastDayReader = reader.createMonthlyReader(lastDay); + + // assert + assertThat(firstDayReader).isNotNull(); + assertThat(midDayReader).isNotNull(); + assertThat(lastDayReader).isNotNull(); + // 모두 같은 월의 1일부터 시작해야 함 + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java new file mode 100644 index 000000000..cf55ad54d --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java @@ -0,0 +1,263 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankCalculationProcessor 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankCalculationProcessorTest { + + @Mock + private ProductRankAggregationProcessor productRankAggregationProcessor; + + private ProductRankCalculationProcessor processor; + + @BeforeEach + void setUp() { + processor = new ProductRankCalculationProcessor(productRankAggregationProcessor); + } + + @DisplayName("랭킹 번호를 1부터 순차적으로 부여한다") + @Test + void assignsRankSequentially() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score1 = createProductRankScore(1L, 10L, 20L, 5L); + ProductRankScore score2 = createProductRankScore(2L, 15L, 25L, 8L); + ProductRankScore score3 = createProductRankScore(3L, 8L, 15L, 3L); + + // act + ProductRank rank1 = processor.process(score1); + ProductRank rank2 = processor.process(score2); + ProductRank rank3 = processor.process(score3); + + // assert + assertThat(rank1).isNotNull(); + assertThat(rank1.getRank()).isEqualTo(1); + assertThat(rank1.getProductId()).isEqualTo(1L); + + assertThat(rank2).isNotNull(); + assertThat(rank2.getRank()).isEqualTo(2); + assertThat(rank2.getProductId()).isEqualTo(2L); + + assertThat(rank3).isNotNull(); + assertThat(rank3.getRank()).isEqualTo(3); + assertThat(rank3.getProductId()).isEqualTo(3L); + } + + @DisplayName("TOP 100에 포함되는 경우 ProductRank를 반환한다") + @Test + void returnsProductRankForTop100() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(1); + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getPeriodType()).isEqualTo(periodType); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(result.getLikeCount()).isEqualTo(10L); + assertThat(result.getSalesCount()).isEqualTo(20L); + assertThat(result.getViewCount()).isEqualTo(5L); + } + + @DisplayName("100번째 처리 후 ThreadLocal이 정리된다") + @Test + void cleansUpThreadLocalAfter100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 99개까지 처리 + for (int i = 1; i <= 99; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + ProductRank result = processor.process(score); + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(i); + } + + // 100번째 처리 (이 시점에서 rank=100이 되고, rank == TOP_RANK_LIMIT이므로 remove() 호출됨) + ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); + ProductRank rank100 = processor.process(score100); + + // assert + assertThat(rank100).isNotNull(); + assertThat(rank100.getRank()).isEqualTo(100); + + // 100번째 처리 후 remove()가 호출되어 ThreadLocal이 정리됨 + // 실제 배치에서는 100번째 이후는 처리되지 않으므로, + // 101번째를 처리하면 currentRank가 0으로 초기화되어 rank=1이 됨 + // 이는 실제 배치 동작과는 다르지만, ThreadLocal 정리 동작을 검증하기 위한 테스트 + ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L); + ProductRank result = processor.process(score101); + + // remove() 후이므로 currentRank가 0으로 초기화되어 rank=1이 되고, + // rank <= 100이므로 ProductRank가 반환됨 + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(1); // remove() 후 다시 1부터 시작 + } + + @DisplayName("정확히 100번째는 ProductRank를 반환한다") + @Test + void returnsProductRankFor100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 99개까지 처리 + for (int i = 1; i <= 99; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + processor.process(score); + } + + // 100번째 처리 + ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score100); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(100); + assertThat(result.getProductId()).isEqualTo(100L); + } + + @DisplayName("기간 정보가 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(null); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("기간 시작일이 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodStartDateNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(ProductRank.PeriodType.WEEKLY); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("주간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithWeeklyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("월간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithMonthlyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 1); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("ProductRankScore의 메트릭 값을 ProductRank에 전달한다") + @Test + void transfersMetricsFromScoreToRank() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 100L, 200L, 50L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLikeCount()).isEqualTo(100L); + assertThat(result.getSalesCount()).isEqualTo(200L); + assertThat(result.getViewCount()).isEqualTo(50L); + } + + /** + * 테스트용 ProductRankScore를 생성합니다. + */ + private ProductRankScore createProductRankScore(Long productId, Long likeCount, Long salesCount, Long viewCount) { + double score = likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + return new ProductRankScore(productId, likeCount, salesCount, viewCount, score); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java new file mode 100644 index 000000000..5aab9868a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java @@ -0,0 +1,251 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * ProductRankScoreAggregationWriter 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankScoreAggregationWriterTest { + + @Mock + private ProductRankScoreRepository productRankScoreRepository; + + @InjectMocks + private ProductRankScoreAggregationWriter writer; + + @DisplayName("Chunk 내에서 같은 product_id를 가진 메트릭을 집계한다") + @Test + void aggregatesMetricsByProductId() throws Exception { + // arrange + List items = new ArrayList<>(); + + // 같은 product_id를 가진 메트릭 2개 + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + metrics1.incrementSalesCount(10); + metrics1.incrementViewCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(1L); + metrics2.incrementLikeCount(); + metrics2.incrementSalesCount(20); + metrics2.incrementViewCount(); + items.add(metrics2); + + // 다른 product_id + ProductMetrics metrics3 = new ProductMetrics(2L); + metrics3.incrementLikeCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(2); + + // product_id=1: 좋아요 2, 판매량 30, 조회수 2 + ProductRankScore score1 = savedScores.stream() + .filter(s -> s.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + assertThat(score1.getLikeCount()).isEqualTo(2L); + assertThat(score1.getSalesCount()).isEqualTo(30L); + assertThat(score1.getViewCount()).isEqualTo(2L); + + // product_id=2: 좋아요 1, 판매량 0, 조회수 0 + ProductRankScore score2 = savedScores.stream() + .filter(s -> s.getProductId().equals(2L)) + .findFirst() + .orElseThrow(); + assertThat(score2.getLikeCount()).isEqualTo(1L); + assertThat(score2.getSalesCount()).isEqualTo(0L); + assertThat(score2.getViewCount()).isEqualTo(0L); + } + + @DisplayName("점수를 올바른 가중치로 계산한다") + @Test + void calculatesScoreWithCorrectWeights() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 1 + metrics.incrementSalesCount(10); // 10 + metrics.incrementViewCount(); // 1 + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 점수 = 1 * 0.3 + 10 * 0.5 + 1 * 0.2 = 0.3 + 5.0 + 0.2 = 5.5 + assertThat(savedScore.getScore()).isEqualTo(5.5); + } + + @DisplayName("기존 데이터가 있으면 누적하여 저장한다") + @Test + void accumulatesWithExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3 + ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1); + when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.of(existingScore)); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 누적: 좋아요 5+1=6, 판매량 20+10=30, 조회수 3+1=4 + assertThat(savedScore.getLikeCount()).isEqualTo(6L); + assertThat(savedScore.getSalesCount()).isEqualTo(30L); + assertThat(savedScore.getViewCount()).isEqualTo(4L); + // 점수 = 6 * 0.3 + 30 * 0.5 + 4 * 0.2 = 1.8 + 15.0 + 0.8 = 17.6 + assertThat(savedScore.getScore()).isEqualTo(17.6); + } + + @DisplayName("빈 Chunk는 처리하지 않는다") + @Test + void skipsEmptyChunk() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act + writer.write(chunk); + + // assert + verify(productRankScoreRepository, never()).findByProductId(anyLong()); + verify(productRankScoreRepository, never()).saveAll(anyList()); + } + + @DisplayName("여러 product_id를 가진 Chunk를 처리한다") + @Test + void processesMultipleProductIds() throws Exception { + // arrange + List items = new ArrayList<>(); + + for (long i = 1; i <= 5; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(5); + + // 각 product_id별로 저장되었는지 확인 + for (long i = 1; i <= 5; i++) { + long productId = i; + ProductRankScore score = savedScores.stream() + .filter(s -> s.getProductId().equals(productId)) + .findFirst() + .orElseThrow(); + assertThat(score.getProductId()).isEqualTo(productId); + assertThat(score.getLikeCount()).isEqualTo(1L); + assertThat(score.getSalesCount()).isEqualTo(productId); + assertThat(score.getViewCount()).isEqualTo(1L); + } + } + + @DisplayName("기존 데이터가 없으면 새로 생성한다") + @Test + void createsNewScoreWhenNoExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + assertThat(savedScore.getProductId()).isEqualTo(1L); + assertThat(savedScore.getLikeCount()).isEqualTo(1L); + assertThat(savedScore.getSalesCount()).isEqualTo(10L); + assertThat(savedScore.getViewCount()).isEqualTo(1L); + } +} + diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 14251dad8..8648e9c8f 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -38,7 +38,23 @@ public void truncateAllTables() { if (!tableName.startsWith("`") && !tableName.endsWith("`")) { tableName = "`" + tableName + "`"; } - entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + + // 테이블이 존재하는지 확인 후 TRUNCATE 수행 + try { + // 테이블 존재 여부 확인 + String checkTableSql = "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = ?"; + Long count = ((Number) entityManager.createNativeQuery(checkTableSql) + .setParameter(1, table.replace("`", "")) + .getSingleResult()).longValue(); + + if (count > 0) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + } + } catch (Exception e) { + // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행 + // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음) + } } entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); diff --git a/settings.gradle.kts b/settings.gradle.kts index 161a1ba24..eeb4fbb90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:commerce-batch", ":apps:pg-simulator", ":apps:commerce-streamer", ":modules:jpa", From 37a09a98144f5921436366d397497cba998a268e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?= =?UTF-8?q?=E1=85=A7=E1=86=BC?= <> Date: Fri, 2 Jan 2026 20:30:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?cdoerabbit=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 트랜젝션 어노테이션 추가 * 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리 * @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성 * 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지 * 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정 * n+1 쿼리 개선 --- .../application/ranking/RankingService.java | 6 +- .../com/loopers/domain/rank/ProductRank.java | 5 +- .../rank/ProductRankRepositoryImpl.java | 2 + .../metrics/ProductMetricsRepository.java | 14 +++- .../com/loopers/domain/rank/ProductRank.java | 5 +- .../rank/ProductRankScoreRepository.java | 12 ++++ .../rank/ProductRankAggregationReader.java | 69 ++++++++++++++++--- .../rank/ProductRankCalculationProcessor.java | 12 ++-- .../rank/ProductRankCalculationReader.java | 2 + .../rank/ProductRankCalculationWriter.java | 2 + .../batch/rank/ProductRankJobConfig.java | 4 ++ .../ProductRankScoreAggregationWriter.java | 22 ++++-- .../metrics/ProductMetricsJpaRepository.java | 13 +++- .../metrics/ProductMetricsRepositoryImpl.java | 1 - .../rank/ProductRankScoreRepositoryImpl.java | 13 ++++ .../ProductRankAggregationReaderTest.java | 60 ++++++++++------ .../ProductRankCalculationProcessorTest.java | 27 ++------ ...ProductRankScoreAggregationWriterTest.java | 16 ++--- 18 files changed, 198 insertions(+), 87 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index d4b0d38d2..b6ebf5fc5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -417,8 +417,10 @@ private RankingsResponse getRankingsFromMaterializedView( Map brandMap = brandService.getBrands(brandIds).stream() .collect(Collectors.toMap(Brand::getId, brand -> brand)); - // 랭킹 항목 생성 + // 랭킹 항목 생성 (순위 재계산: 누락된 항목 제외 후 연속 순위 부여) List rankingItems = new ArrayList<>(); + long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위) + for (com.loopers.domain.rank.ProductRank rank : pagedRanks) { Long productId = rank.getProductId(); Product product = productMap.get(productId); @@ -445,7 +447,7 @@ private RankingsResponse getRankingsFromMaterializedView( double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount()); rankingItems.add(new RankingItem( - rank.getRank().longValue(), + currentRank++, // 연속 순위 부여 score, productDetail )); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java index 30abae5d3..22dfd22c9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -16,8 +16,9 @@ *

* Materialized View 설계: *

    - *
  • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
  • - *
  • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
  • + *
  • 테이블: `mv_product_rank` (단일 테이블)
  • + *
  • 주간 랭킹: period_type = WEEKLY
  • + *
  • 월간 랭킹: period_type = MONTHLY
  • *
  • TOP 100만 저장하여 조회 성능 최적화
  • *
*

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java index 046c6a035..d995ff486 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -6,6 +6,7 @@ import jakarta.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.List; @@ -19,6 +20,7 @@ */ @Slf4j @Repository +@Transactional(readOnly = true) public class ProductRankRepositoryImpl implements ProductRankRepository { @PersistenceContext diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index aa831ba5a..4df1e6311 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -54,9 +54,18 @@ public interface ProductMetricsRepository { * Spring Batch의 JpaPagingItemReader에서 사용됩니다. * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. *

+ *

+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

    + *
  • startDateTime: 2024-12-15 00:00:00
  • + *
  • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
  • + *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

* - * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) - * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) * @param pageable 페이징 정보 * @return 조회된 메트릭 페이지 */ @@ -80,7 +89,6 @@ Page findByUpdatedAtBetween( * * @return PagingAndSortingRepository를 구현한 JPA Repository */ - @SuppressWarnings("rawtypes") org.springframework.data.repository.PagingAndSortingRepository getJpaRepository(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java index 576eb158d..42a261c97 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -16,8 +16,9 @@ *

* Materialized View 설계: *

    - *
  • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
  • - *
  • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
  • + *
  • 테이블: `mv_product_rank` (단일 테이블)
  • + *
  • 주간 랭킹: period_type = WEEKLY
  • + *
  • 월간 랭킹: period_type = MONTHLY
  • *
  • TOP 100만 저장하여 조회 성능 최적화
  • *
*

diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java index 149357a81..efb09527d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** * ProductRankScore 도메인 Repository 인터페이스. @@ -39,6 +40,17 @@ public interface ProductRankScoreRepository { */ Optional findByProductId(Long productId); + /** + * 여러 product_id로 ProductRankScore를 일괄 조회합니다. + *

+ * N+1 쿼리 문제를 방지하기 위해 사용합니다. + *

+ * + * @param productIds 상품 ID 집합 + * @return ProductRankScore 리스트 + */ + List findAllByProductIdIn(Set productIds); + /** * 모든 ProductRankScore를 점수 내림차순으로 조회합니다. *

diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java index 449cb18d2..3f58bc891 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java @@ -50,6 +50,24 @@ public class ProductRankAggregationReader { * @return RepositoryItemReader 인스턴스 */ public RepositoryItemReader createWeeklyReader(LocalDate targetDate) { + DateRange weekRange = calculateWeeklyRange(targetDate); + + log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", + targetDate, weekRange.startDate(), weekRange.endDate()); + + return createReader(weekRange.startDateTime(), weekRange.endDateTime(), "weeklyReader"); + } + + /** + * 주간 범위를 계산합니다. + *

+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

+ * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return 주간 범위 (시작일, 종료일) + */ + DateRange calculateWeeklyRange(LocalDate targetDate) { // 주간 시작일 계산 (월요일) LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); LocalDateTime startDateTime = weekStart.atStartOfDay(); @@ -57,11 +75,8 @@ public RepositoryItemReader createWeeklyReader(LocalDate targetD // 주간 종료일 계산 (다음 주 월요일 00:00:00) LocalDate weekEnd = weekStart.plusWeeks(1); LocalDateTime endDateTime = weekEnd.atStartOfDay(); - - log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", - targetDate, weekStart, weekEnd); - - return createReader(startDateTime, endDateTime, "weeklyReader"); + + return new DateRange(weekStart, weekEnd, startDateTime, endDateTime); } /** @@ -74,6 +89,24 @@ public RepositoryItemReader createWeeklyReader(LocalDate targetD * @return RepositoryItemReader 인스턴스 */ public RepositoryItemReader createMonthlyReader(LocalDate targetDate) { + DateRange monthRange = calculateMonthlyRange(targetDate); + + log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", + targetDate, monthRange.startDate(), monthRange.endDate()); + + return createReader(monthRange.startDateTime(), monthRange.endDateTime(), "monthlyReader"); + } + + /** + * 월간 범위를 계산합니다. + *

+ * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

+ * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return 월간 범위 (시작일, 종료일) + */ + DateRange calculateMonthlyRange(LocalDate targetDate) { // 월간 시작일 계산 (1일) LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth()); LocalDateTime startDateTime = monthStart.atStartOfDay(); @@ -81,11 +114,8 @@ public RepositoryItemReader createMonthlyReader(LocalDate target // 월간 종료일 계산 (다음 달 1일 00:00:00) LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth()); LocalDateTime endDateTime = monthEnd.atStartOfDay(); - - log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", - targetDate, monthStart, monthEnd); - - return createReader(startDateTime, endDateTime, "monthlyReader"); + + return new DateRange(monthStart, monthEnd, startDateTime, endDateTime); } /** @@ -119,5 +149,24 @@ private RepositoryItemReader createReader( .sorts(sorts) .build(); } + + /** + * 날짜 범위를 담는 레코드. + *

+ * 테스트 가능성을 위해 내부 클래스로 정의했습니다. + *

+ * + * @param startDate 시작일 + * @param endDate 종료일 (exclusive) + * @param startDateTime 시작 시각 + * @param endDateTime 종료 시각 (exclusive) + */ + record DateRange( + LocalDate startDate, + LocalDate endDate, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ) { + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java index cafcbc4cc..159138dae 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankScore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.ItemProcessor; import org.springframework.stereotype.Component; @@ -29,11 +30,12 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationProcessor implements ItemProcessor { private final ProductRankAggregationProcessor productRankAggregationProcessor; - private final ThreadLocal currentRank = ThreadLocal.withInitial(() -> 0); + private int currentRank = 0; private static final int TOP_RANK_LIMIT = 100; /** @@ -48,8 +50,7 @@ public class ProductRankCalculationProcessor implements ItemProcessor TOP_RANK_LIMIT) { @@ -76,11 +77,6 @@ public ProductRank process(ProductRankScore score) throws Exception { score.getViewCount() ); - // Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시) - if (rank == TOP_RANK_LIMIT) { - currentRank.remove(); - } - return productRank; } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java index 4b997f66c..679d1d823 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankScoreRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.NonTransientResourceException; import org.springframework.batch.item.ParseException; @@ -33,6 +34,7 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationReader implements ItemReader { diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java index 71fd8ea5c..40530a10a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.stereotype.Component; @@ -32,6 +33,7 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationWriter implements ItemWriter { diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java index a8c06a0e5..875bd3519 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -181,6 +181,10 @@ public ItemReader productRankReader( @Value("#{jobParameters['periodType']}") String periodType, @Value("#{jobParameters['targetDate']}") String targetDate ) { + if (periodType == null || periodType.isEmpty()) { + throw new IllegalArgumentException("periodType 파라미터는 필수입니다. (WEEKLY 또는 MONTHLY)"); + } + LocalDate date = parseDate(targetDate); ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase()); diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java index f1e3d6404..59a8da624 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java @@ -11,6 +11,8 @@ import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -80,25 +82,31 @@ public void write(Chunk chunk) throws Exception { ) )); + // Chunk 내 모든 productId를 한 번에 조회 + Set productIds = chunkAggregatedMap.keySet(); + Map existingScores = productRankScoreRepository + .findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(ProductRankScore::getProductId, Function.identity())); + // 기존 데이터와 누적하여 ProductRankScore 생성 List scores = chunkAggregatedMap.entrySet().stream() .map(entry -> { Long productId = entry.getKey(); AggregatedMetrics chunkAggregated = entry.getValue(); - // 기존 데이터 조회 - java.util.Optional existing = productRankScoreRepository.findByProductId(productId); + // 기존 데이터 조회 (일괄 조회 결과에서) + ProductRankScore existing = existingScores.get(productId); // 기존 데이터와 누적 Long totalLikeCount = chunkAggregated.getLikeCount(); Long totalSalesCount = chunkAggregated.getSalesCount(); Long totalViewCount = chunkAggregated.getViewCount(); - if (existing.isPresent()) { - ProductRankScore existingScore = existing.get(); - totalLikeCount += existingScore.getLikeCount(); - totalSalesCount += existingScore.getSalesCount(); - totalViewCount += existingScore.getViewCount(); + if (existing != null) { + totalLikeCount += existing.getLikeCount(); + totalSalesCount += existing.getSalesCount(); + totalViewCount += existing.getViewCount(); } // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2) diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index e76dd736f..ff10e63c2 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -40,9 +40,18 @@ public interface ProductMetricsJpaRepository extends JpaRepository + *

+ * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

    + *
  • startDateTime: 2024-12-15 00:00:00
  • + *
  • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
  • + *
+ * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

* - * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) - * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) * @param pageable 페이징 정보 * @return 조회된 메트릭 페이지 */ diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 70b775e30..51d974de5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -65,7 +65,6 @@ public Page findByUpdatedAtBetween( * {@inheritDoc} */ @Override - @SuppressWarnings("rawtypes") public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() { return productMetricsJpaRepository; } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java index b210d9ce2..3037e8f99 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** * ProductRankScore Repository 구현체. @@ -70,6 +71,18 @@ public Optional findByProductId(Long productId) { } } + @Override + public List findAllByProductIdIn(Set productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId IN :productIds"; + return entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productIds", productIds) + .getResultList(); + } + @Override public List findAllOrderByScoreDesc(int limit) { String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC"; diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java index 50e225b7e..286108683 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java @@ -49,17 +49,18 @@ void createsWeeklyReader() { void weeklyReaderQueriesFromMondayToNextMonday() { // arrange LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // act - RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + ProductRankAggregationReader.DateRange range = reader.calculateWeeklyRange(targetDate); // assert - assertThat(itemReader).isNotNull(); - // 주간 시작일은 해당 주의 월요일이어야 함 // 2024-12-15(일) -> 2024-12-09(월)이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2024, 12, 16)); // 다음 주 월요일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 9).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2024, 12, 16).atStartOfDay()); } @DisplayName("월간 Reader를 생성할 수 있다") @@ -84,24 +85,24 @@ void createsMonthlyReader() { void monthlyReaderQueriesFromFirstDayToNextMonth() { // arrange LocalDate targetDate = LocalDate.of(2024, 12, 15); - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // act - RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + ProductRankAggregationReader.DateRange range = reader.calculateMonthlyRange(targetDate); // assert - assertThat(itemReader).isNotNull(); - // 월간 시작일은 해당 월의 1일이어야 함 // 2024-12-15 -> 2024-12-01이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 1일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2025, 1, 1)); // 다음 달 1일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 1).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2025, 1, 1).atStartOfDay()); } @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다") @Test void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { // arrange - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // 월요일 @@ -112,22 +113,29 @@ void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { LocalDate sunday = LocalDate.of(2024, 12, 15); // act - RepositoryItemReader mondayReader = reader.createWeeklyReader(monday); - RepositoryItemReader wednesdayReader = reader.createWeeklyReader(wednesday); - RepositoryItemReader sundayReader = reader.createWeeklyReader(sunday); + ProductRankAggregationReader.DateRange mondayRange = reader.calculateWeeklyRange(monday); + ProductRankAggregationReader.DateRange wednesdayRange = reader.calculateWeeklyRange(wednesday); + ProductRankAggregationReader.DateRange sundayRange = reader.calculateWeeklyRange(sunday); // assert - assertThat(mondayReader).isNotNull(); - assertThat(wednesdayReader).isNotNull(); - assertThat(sundayReader).isNotNull(); // 모두 같은 주의 월요일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 9); // 월요일 + LocalDate expectedEnd = LocalDate.of(2024, 12, 16); // 다음 주 월요일 + + assertThat(mondayRange.startDate()).isEqualTo(expectedStart); + assertThat(mondayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(wednesdayRange.startDate()).isEqualTo(expectedStart); + assertThat(wednesdayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(sundayRange.startDate()).isEqualTo(expectedStart); + assertThat(sundayRange.endDate()).isEqualTo(expectedEnd); } @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다") @Test void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { // arrange - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // 1일 @@ -138,15 +146,23 @@ void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { LocalDate lastDay = LocalDate.of(2024, 12, 31); // act - RepositoryItemReader firstDayReader = reader.createMonthlyReader(firstDay); - RepositoryItemReader midDayReader = reader.createMonthlyReader(midDay); - RepositoryItemReader lastDayReader = reader.createMonthlyReader(lastDay); + ProductRankAggregationReader.DateRange firstDayRange = reader.calculateMonthlyRange(firstDay); + ProductRankAggregationReader.DateRange midDayRange = reader.calculateMonthlyRange(midDay); + ProductRankAggregationReader.DateRange lastDayRange = reader.calculateMonthlyRange(lastDay); // assert - assertThat(firstDayReader).isNotNull(); - assertThat(midDayReader).isNotNull(); - assertThat(lastDayReader).isNotNull(); // 모두 같은 월의 1일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 1); // 1일 + LocalDate expectedEnd = LocalDate.of(2025, 1, 1); // 다음 달 1일 + + assertThat(firstDayRange.startDate()).isEqualTo(expectedStart); + assertThat(firstDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(midDayRange.startDate()).isEqualTo(expectedStart); + assertThat(midDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(lastDayRange.startDate()).isEqualTo(expectedStart); + assertThat(lastDayRange.endDate()).isEqualTo(expectedEnd); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java index cf55ad54d..2bd675457 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java @@ -89,9 +89,9 @@ void returnsProductRankForTop100() throws Exception { assertThat(result.getViewCount()).isEqualTo(5L); } - @DisplayName("100번째 처리 후 ThreadLocal이 정리된다") + @DisplayName("101번째 이후는 null을 반환한다 (TOP 100 초과)") @Test - void cleansUpThreadLocalAfter100th() throws Exception { + void returnsNullAfter100th() throws Exception { // arrange ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; LocalDate periodStartDate = LocalDate.of(2024, 12, 9); @@ -99,33 +99,20 @@ void cleansUpThreadLocalAfter100th() throws Exception { when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); - // 99개까지 처리 - for (int i = 1; i <= 99; i++) { + // 100개까지 처리 + for (int i = 1; i <= 100; i++) { ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); ProductRank result = processor.process(score); assertThat(result).isNotNull(); assertThat(result.getRank()).isEqualTo(i); } - // 100번째 처리 (이 시점에서 rank=100이 되고, rank == TOP_RANK_LIMIT이므로 remove() 호출됨) - ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); - ProductRank rank100 = processor.process(score100); - - // assert - assertThat(rank100).isNotNull(); - assertThat(rank100.getRank()).isEqualTo(100); - - // 100번째 처리 후 remove()가 호출되어 ThreadLocal이 정리됨 - // 실제 배치에서는 100번째 이후는 처리되지 않으므로, - // 101번째를 처리하면 currentRank가 0으로 초기화되어 rank=1이 됨 - // 이는 실제 배치 동작과는 다르지만, ThreadLocal 정리 동작을 검증하기 위한 테스트 + // 101번째 처리 (rank > TOP_RANK_LIMIT이므로 null 반환) ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L); ProductRank result = processor.process(score101); - // remove() 후이므로 currentRank가 0으로 초기화되어 rank=1이 되고, - // rank <= 100이므로 ProductRank가 반환됨 - assertThat(result).isNotNull(); - assertThat(result.getRank()).isEqualTo(1); // remove() 후 다시 1부터 시작 + // assert + assertThat(result).isNull(); // TOP 100 초과이므로 null 반환 } @DisplayName("정확히 100번째는 ProductRank를 반환한다") diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java index 5aab9868a..626d3ee2f 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java @@ -14,11 +14,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.*; /** @@ -59,7 +59,7 @@ void aggregatesMetricsByProductId() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -106,7 +106,7 @@ void calculatesScoreWithCorrectWeights() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -138,7 +138,7 @@ void accumulatesWithExistingData() throws Exception { // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3 ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1); - when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.of(existingScore)); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of(existingScore)); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -168,7 +168,7 @@ void skipsEmptyChunk() throws Exception { writer.write(chunk); // assert - verify(productRankScoreRepository, never()).findByProductId(anyLong()); + verify(productRankScoreRepository, never()).findAllByProductIdIn(anySet()); verify(productRankScoreRepository, never()).saveAll(anyList()); } @@ -188,7 +188,7 @@ void processesMultipleProductIds() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -230,7 +230,7 @@ void createsNewScoreWhenNoExistingData() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act