diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java index 60f3397fb2..bf0f7187bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java @@ -27,53 +27,8 @@ public class RankingFacade { @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankings(String date, int page, int size) { - // 1. ZSET에서 Top-N 조회 (Redis) - // ZREVRANGE ranking:all:{date} {offset} {offset+size-1} WITHSCORES List entries = rankingService.getTopRankings(date, page, size); - - if (entries.isEmpty()) { - return new RankingInfo.RankingPageResponse(List.of(), page, size); - } - - // 2. productId 목록으로 상품 정보 일괄 조회 (DB) - // ZSET에는 productId만 있으므로, 상품명/가격/이미지 등은 DB에서 조회 - List productIds = entries.stream() - .map(RankingEntry::productId) - .toList(); - Map productMap = productService.getByIds(productIds).stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); - - // 3. 브랜드 정보 일괄 조회 (DB) - // 상품의 brandId로 브랜드명을 가져온다 (N+1 방지를 위해 일괄 조회) - Set brandIds = productMap.values().stream() - .map(ProductModel::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - // 4. ZSET 결과 + 상품 정보 + 브랜드 정보를 조합하여 응답 구성 - int baseRank = (page - 1) * size; // 페이지별 기준 순위 (page=2, size=20이면 20) - List rankings = new ArrayList<>(); - - for (int i = 0; i < entries.size(); i++) { - RankingEntry entry = entries.get(i); - ProductModel product = productMap.get(entry.productId()); - if (product == null) continue; // 삭제된 상품은 skip - - BrandModel brand = brandMap.get(product.getBrandId()); - String brandName = brand != null ? brand.getName() : "Unknown"; - - rankings.add(new RankingInfo.RankingItem( - baseRank + i + 1, - entry.score(), - product.getId(), - product.getName(), - brandName, - product.getPrice(), - product.getImageUrl() - )); - } - - return new RankingInfo.RankingPageResponse(rankings, page, size); + return assembleResponse(entries, page, size); } /** @@ -83,22 +38,48 @@ public RankingInfo.RankingPageResponse getRankings(String date, int page, int si @Transactional(readOnly = true) public RankingInfo.RankingPageResponse getRankingsFromDB(int page, int size) { List entries = rankingService.getTopRankingsFromDB(page, size); + return assembleResponse(entries, page, size); + } + + /** 주간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */ + @Transactional(readOnly = true) + public RankingInfo.RankingPageResponse getRankingsWeekly(String date, int page, int size) { + List entries = rankingService.getTopRankingsWeekly(date, page, size); + return assembleResponse(entries, page, size); + } + /** 월간 랭킹 조회: MV 테이블 → 상품/브랜드 Aggregation */ + @Transactional(readOnly = true) + public RankingInfo.RankingPageResponse getRankingsMonthly(String date, int page, int size) { + List entries = rankingService.getTopRankingsMonthly(date, page, size); + return assembleResponse(entries, page, size); + } + + /** + * RankingEntry 목록을 상품/브랜드 정보와 조합하여 응답을 구성한다. + * 일간/주간/월간 모두 동일한 Aggregation 로직을 사용하므로 공통 메서드로 추출. + */ + private RankingInfo.RankingPageResponse assembleResponse( + List entries, int page, int size + ) { if (entries.isEmpty()) { return new RankingInfo.RankingPageResponse(List.of(), page, size); } + // 상품 정보 일괄 조회 List productIds = entries.stream() .map(RankingEntry::productId) .toList(); Map productMap = productService.getByIds(productIds).stream() .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + // 브랜드 정보 일괄 조회 Set brandIds = productMap.values().stream() .map(ProductModel::getBrandId) .collect(Collectors.toSet()); Map brandMap = brandService.getByIds(brandIds); + // 응답 조합 int baseRank = (page - 1) * size; List rankings = new ArrayList<>(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java new file mode 100644 index 0000000000..6cc2805583 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyReadModel.java @@ -0,0 +1,39 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import org.hibernate.annotations.Immutable; + +@Entity +@Immutable +@Table(name = "mv_product_rank_monthly") +@Getter +public class MvProductRankMonthlyReadModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "`year_month`", nullable = false, length = 10) + private String yearMonth; + + protected MvProductRankMonthlyReadModel() {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java new file mode 100644 index 0000000000..8f6e69291a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyReadModel.java @@ -0,0 +1,44 @@ +package com.loopers.domain.ranking; + +import jakarta.persistence.*; +import lombok.Getter; +import org.hibernate.annotations.Immutable; + +/** + * 주간 MV 읽기 전용 엔티티 (commerce-api용). + * commerce-batch에서 적재한 mv_product_rank_weekly 테이블을 조회만 한다. + * @Immutable: Hibernate의 dirty checking 대상에서 제외 (읽기 전용 최적화) + */ +@Entity +@Immutable +@Table(name = "mv_product_rank_weekly") +@Getter +public class MvProductRankWeeklyReadModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; + + protected MvProductRankWeeklyReadModel() {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java index b9a13060d9..918bbb414c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java @@ -12,4 +12,10 @@ public interface RankingRepository { /** DB ORDER BY 기반 Top-N 조회 — ZSET 성능 비교용 */ List getTopNFromDB(int offset, int size); + + /** MV 주간 랭킹 조회 — yearWeek(예: "2026W15") 기준 */ + List getTopNWeekly(String yearWeek, int offset, int size); + + /** MV 월간 랭킹 조회 — yearMonth(예: "202604") 기준 */ + List getTopNMonthly(String yearMonth, int offset, int size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java index f60ea5ae68..ce235ace3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -47,4 +47,25 @@ public Long getProductRank(Long productId) { Long rank = rankingRepository.getRank(key, productId); return rank != null ? rank + 1 : null; // 0-based → 1-based } + + /** 주간 랭킹 조회: date(yyyyMMdd)에서 yearWeek를 계산하여 MV 테이블 조회 */ + public List getTopRankingsWeekly(String date, int page, int size) { + String yearWeek = computeYearWeek(date); + int offset = (page - 1) * size; + return rankingRepository.getTopNWeekly(yearWeek, offset, size); + } + + /** 월간 랭킹 조회: date(yyyyMMdd)에서 yearMonth를 계산하여 MV 테이블 조회 */ + public List getTopRankingsMonthly(String date, int page, int size) { + String yearMonth = date.substring(0, 6); // "yyyyMMdd" → "yyyyMM" + int offset = (page - 1) * size; + return rankingRepository.getTopNMonthly(yearMonth, offset, size); + } + + private String computeYearWeek(String dateStr) { + LocalDate date = LocalDate.parse(dateStr, DATE_FORMAT); + int year = date.get(java.time.temporal.WeekFields.ISO.weekBasedYear()); + int week = date.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear()); + return String.format("%dW%02d", year, week); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..862dfe055a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthlyReadModel; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + /** 특정 월의 랭킹을 순위순으로 페이징 조회 */ + List findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..eab6daa134 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeeklyReadModel; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + /** 특정 주차의 랭킹을 순위순으로 페이징 조회 */ + List findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java index c60be33d88..6a10329551 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRedisRepository.java @@ -3,6 +3,7 @@ import com.loopers.domain.ranking.RankingEntry; import com.loopers.domain.ranking.RankingRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Component; @@ -16,6 +17,8 @@ public class RankingRedisRepository implements RankingRepository { private final RedisTemplate redisTemplate; private final ProductMetricsJpaRepository productMetricsJpaRepository; + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; @Override public List getTopN(String key, int offset, int size) { @@ -53,4 +56,22 @@ public List getTopNFromDB(int offset, int size) { ((Number) row[1]).doubleValue())) .toList(); } + + @Override + public List getTopNWeekly(String yearWeek, int offset, int size) { + int page = offset / size; + return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size)) + .stream() + .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) + .toList(); + } + + @Override + public List getTopNMonthly(String yearMonth, int offset, int size) { + int page = offset / size; + return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size)) + .stream() + .map(mv -> new RankingEntry(mv.getProductId(), mv.getScore())) + .toList(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java index a2cb8e703e..53cb7d8c3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java @@ -8,8 +8,9 @@ @Tag(name = "Ranking V1 API", description = "상품 랭킹 API") public interface RankingV1ApiSpec { - @Operation(summary = "랭킹 조회", description = "날짜별 상품 랭킹을 조회합니다.") + @Operation(summary = "랭킹 조회", description = "기간별 상품 랭킹을 조회합니다. (일간/주간/월간)") ApiResponse getRankings( + @Parameter(description = "기간 (daily/weekly/monthly, 기본 daily)") String period, @Parameter(description = "조회 날짜 (yyyyMMdd), 미지정 시 오늘") String date, @Parameter(description = "페이지 크기 (기본 20)") int size, @Parameter(description = "페이지 번호 (1부터, 기본 1)") int page 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 4e2aaf458c..8be2f8b7dd 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 @@ -3,6 +3,8 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,6 +13,8 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Set; @RequiredArgsConstructor @RestController @@ -21,22 +25,42 @@ public class RankingV1Controller implements RankingV1ApiSpec { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Set VALID_PERIODS = Set.of("daily", "weekly", "monthly"); @GetMapping @Override public ApiResponse getRankings( + @RequestParam(defaultValue = "daily") String period, @RequestParam(required = false) String date, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "1") int page ) { - // date 미지정 시 오늘 날짜를 기본값으로 사용 + if (!VALID_PERIODS.contains(period)) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 기간입니다: " + period); + } + if (page < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다."); + } + if (size < 1 || size > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "size는 1~100 범위여야 합니다."); + } + if (date == null || date.isBlank()) { date = LocalDate.now().format(DATE_FORMAT); + } else { + try { + LocalDate.parse(date, DATE_FORMAT); + } catch (DateTimeParseException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "날짜 형식이 올바르지 않습니다. (yyyyMMdd)"); + } } - // Facade에서 ZSET 조회 + 상품/브랜드 Aggregation 수행 - RankingInfo.RankingPageResponse info = rankingFacade.getRankings(date, page, size); - // application 레이어 Info → interfaces 레이어 DTO 변환 + RankingInfo.RankingPageResponse info = switch (period) { + case "weekly" -> rankingFacade.getRankingsWeekly(date, page, size); + case "monthly" -> rankingFacade.getRankingsMonthly(date, page, size); + default -> rankingFacade.getRankings(date, page, size); + }; + return ApiResponse.success(RankingV1Dto.RankingPageResponse.from(info)); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java deleted file mode 100644 index 7c486483f5..0000000000 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.batch.job.demo; - -import com.loopers.batch.job.demo.step.DemoTasklet; -import com.loopers.batch.listener.JobListener; -import com.loopers.batch.listener.StepMonitorListener; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.JobScope; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.launch.support.RunIdIncrementer; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.support.transaction.ResourcelessTransactionManager; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) -@RequiredArgsConstructor -@Configuration -public class DemoJobConfig { - public static final String JOB_NAME = "demoJob"; - private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; - - private final JobRepository jobRepository; - private final JobListener jobListener; - private final StepMonitorListener stepMonitorListener; - private final DemoTasklet demoTasklet; - - @Bean(JOB_NAME) - public Job demoJob() { - return new JobBuilder(JOB_NAME, jobRepository) - .incrementer(new RunIdIncrementer()) - .start(categorySyncStep()) - .listener(jobListener) - .build(); - } - - @JobScope - @Bean(STEP_DEMO_SIMPLE_TASK_NAME) - public Step categorySyncStep() { - return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) - .tasklet(demoTasklet, new ResourcelessTransactionManager()) - .listener(stepMonitorListener) - .build(); - } -} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java new file mode 100644 index 0000000000..9c76e18f06 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/ProductMetricsRow.java @@ -0,0 +1,20 @@ +package com.loopers.batch.job.ranking; + +/** + * product_metrics 테이블의 1행을 담는 읽기 전용 DTO. + * JdbcCursorItemReader의 RowMapper에서 생성된다. + * commerce-batch에는 ProductMetricsModel 엔티티가 없으므로 JDBC + record로 처리. + * + * @param productId 상품 ID + * @param viewCount 누적 조회수 + * @param likeCount 누적 좋아요수 + * @param salesCount 누적 판매건수 + * @param score 가중치 점수 (SQL에서 계산: view*0.1 + like*0.2 + sales*0.7) + */ +public record ProductMetricsRow( + Long productId, + long viewCount, + long likeCount, + long salesCount, + double score +) {} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java new file mode 100644 index 0000000000..23aac35af9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java @@ -0,0 +1,134 @@ +package com.loopers.batch.job.ranking.monthly; + +import com.loopers.batch.job.ranking.ProductMetricsRow; +import com.loopers.batch.job.ranking.monthly.step.ClearMonthlyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankMonthlyModel; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.concurrent.atomic.AtomicInteger; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class MonthlyRankingJobConfig { + + public static final String JOB_NAME = "monthlyRankingJob"; + private static final String STEP_CLEAR = "clearMonthlyRankingStep"; + private static final String STEP_AGGREGATE = "aggregateMonthlyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ClearMonthlyRankingTasklet clearMonthlyRankingTasklet; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(clearMonthlyRankingStep()) + .next(aggregateMonthlyRankingStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_CLEAR) + public Step clearMonthlyRankingStep() { + return new StepBuilder(STEP_CLEAR, jobRepository) + .tasklet(clearMonthlyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateMonthlyRankingStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader(null)) + .processor(monthlyRankingProcessor(null)) + .writer(monthlyRankingWriter(null)) + .listener(stepMonitorListener) + .build(); + } + + // Reader — 주간과 동일한 SQL (product_metrics에서 TOP 100) + @StepScope + @Bean + public JdbcCursorItemReader monthlyRankingReader(DataSource dataSource) { + return new JdbcCursorItemReaderBuilder() + .name("monthlyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, + pm.view_count, + pm.like_count, + pm.sales_count, + (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score + FROM product_metrics pm + ORDER BY score DESC + LIMIT 100 + """) + .rowMapper((rs, rowNum) -> new ProductMetricsRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count"), + rs.getDouble("score"))) + .build(); + } + + // Processor — yearMonth 계산 + 순위 부여 (차이점: yearWeek 대신 yearMonth) + @StepScope + @Bean + public ItemProcessor monthlyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearMonth = ClearMonthlyRankingTasklet.computeYearMonth(requestDate); + + return item -> new MvProductRankMonthlyModel( + item.productId(), + item.viewCount(), + item.likeCount(), + item.salesCount(), + item.score(), + rankCounter.incrementAndGet(), + yearMonth + ); + } + + @StepScope + @Bean + public JpaItemWriter monthlyRankingWriter( + EntityManagerFactory entityManagerFactory + ) { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java similarity index 51% rename from apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java rename to apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java index 800fe5a03c..a45195e43d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/ClearMonthlyRankingTasklet.java @@ -1,7 +1,9 @@ -package com.loopers.batch.job.demo.step; +package com.loopers.batch.job.ranking.monthly.step; -import com.loopers.batch.job.demo.DemoJobConfig; +import com.loopers.batch.job.ranking.monthly.MonthlyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankMonthlyJpaRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.scope.context.ChunkContext; @@ -11,22 +13,28 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +@Slf4j @StepScope -@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) @RequiredArgsConstructor @Component -public class DemoTasklet implements Tasklet { +public class ClearMonthlyRankingTasklet implements Tasklet { + + private final MvProductRankMonthlyJpaRepository monthlyJpaRepository; + @Value("#{jobParameters['requestDate']}") private String requestDate; @Override - public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { - if (requestDate == null) { - throw new RuntimeException("requestDate is null"); - } - System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); - Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearMonth = computeYearMonth(requestDate); + log.info("월간 랭킹 MV 초기화: yearMonth={}", yearMonth); + monthlyJpaRepository.deleteByYearMonth(yearMonth); return RepeatStatus.FINISHED; } + + /** "yyyyMMdd" → "yyyyMM". 예: "20260412" → "202604" */ + public static String computeYearMonth(String dateStr) { + return dateStr.substring(0, 6); + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java new file mode 100644 index 0000000000..0aa19996ef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java @@ -0,0 +1,177 @@ +package com.loopers.batch.job.ranking.weekly; + +import com.loopers.batch.job.ranking.ProductMetricsRow; +import com.loopers.batch.job.ranking.weekly.step.ClearWeeklyRankingTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JdbcCursorItemReader; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.builder.JdbcCursorItemReaderBuilder; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 주간 랭킹 집계 배치 Job 설정. + * + * Job 구조: + * Step 1 (Tasklet) — 해당 주차 기존 MV 데이터 삭제 + * Step 2 (Chunk) — product_metrics 읽기 → 점수 계산/순위 부여 → MV 적재 + * + * 실행: + * ./gradlew :apps:commerce-batch:bootRun \ + * --args="--spring.batch.job.name=weeklyRankingJob --job.name=weeklyRankingJob --requestDate=20260412" + */ +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + private static final String STEP_CLEAR = "clearWeeklyRankingStep"; + private static final String STEP_AGGREGATE = "aggregateWeeklyRankingStep"; + private static final int CHUNK_SIZE = 100; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final ClearWeeklyRankingTasklet clearWeeklyRankingTasklet; + + // ============================================================ + // Job 정의 + // ============================================================ + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(clearWeeklyRankingStep()) // Step 1: 기존 데이터 삭제 + .next(aggregateWeeklyRankingStep()) // Step 2: 집계 + 적재 + .listener(jobListener) + .build(); + } + + // ============================================================ + // Step 1: 기존 MV 데이터 삭제 (Tasklet) + // ============================================================ + + @JobScope + @Bean(STEP_CLEAR) + public Step clearWeeklyRankingStep() { + return new StepBuilder(STEP_CLEAR, jobRepository) + .tasklet(clearWeeklyRankingTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } + + // ============================================================ + // Step 2: 집계 + 적재 (Chunk-Oriented) + // ============================================================ + + @JobScope + @Bean(STEP_AGGREGATE) + public Step aggregateWeeklyRankingStep() { + return new StepBuilder(STEP_AGGREGATE, jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader(null)) // DataSource는 Spring이 주입 + .processor(weeklyRankingProcessor(null)) // requestDate는 @StepScope에서 주입 + .writer(weeklyRankingWriter(null)) // EntityManagerFactory는 Spring이 주입 + .listener(stepMonitorListener) + .build(); + } + + // ---- Reader: product_metrics에서 가중치 점수 기준 TOP 100 조회 ---- + + /** + * JdbcCursorItemReader: DB 커서를 열고 1행씩 읽는다. + * commerce-batch에는 ProductMetricsModel 엔티티가 없으므로 JDBC로 직접 읽는다. + * + * SQL 설명: + * - product_metrics의 모든 행에 가중치 점수를 계산 + * - 점수 내림차순으로 정렬하여 상위 100건만 조회 + * - 가중치: view * 0.1 + like * 0.2 + salesCount * 0.7 + */ + @StepScope + @Bean + public JdbcCursorItemReader weeklyRankingReader(DataSource dataSource) { + return new JdbcCursorItemReaderBuilder() + .name("weeklyRankingReader") + .dataSource(dataSource) + .sql(""" + SELECT pm.product_id, + pm.view_count, + pm.like_count, + pm.sales_count, + (pm.view_count * 0.1 + pm.like_count * 0.2 + pm.sales_count * 0.7) AS score + FROM product_metrics pm + ORDER BY score DESC + LIMIT 100 + """) + .rowMapper((rs, rowNum) -> new ProductMetricsRow( + rs.getLong("product_id"), + rs.getLong("view_count"), + rs.getLong("like_count"), + rs.getLong("sales_count"), + rs.getDouble("score"))) + .build(); + } + + // ---- Processor: 점수 계산 + yearWeek 계산 + 순위 부여 ---- + + /** + * ProductMetricsRow → MvProductRankWeeklyModel 변환. + * - requestDate로부터 yearWeek을 계산한다 + * - rankCounter로 읽은 순서대로 순위를 부여한다 (Reader가 점수 DESC로 정렬했으므로) + * + * @StepScope: Step마다 새 인스턴스 생성 → rankCounter가 0으로 리셋됨 + */ + @StepScope + @Bean + public ItemProcessor weeklyRankingProcessor( + @Value("#{jobParameters['requestDate']}") String requestDate + ) { + AtomicInteger rankCounter = new AtomicInteger(0); + String yearWeek = ClearWeeklyRankingTasklet.computeYearWeek(requestDate); + + return item -> new MvProductRankWeeklyModel( + item.productId(), + item.viewCount(), + item.likeCount(), + item.salesCount(), + item.score(), + rankCounter.incrementAndGet(), // 1, 2, 3, ... 순위 부여 + yearWeek + ); + } + + // ---- Writer: MV 테이블에 JPA로 저장 ---- + + @StepScope + @Bean + public JpaItemWriter weeklyRankingWriter( + EntityManagerFactory entityManagerFactory + ) { + return new JpaItemWriterBuilder() + .entityManagerFactory(entityManagerFactory) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java new file mode 100644 index 0000000000..c507d3051d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/ClearWeeklyRankingTasklet.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.weekly.step; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.WeekFields; + +/** + * 주간 MV 테이블에서 해당 주차의 기존 데이터를 삭제하는 Tasklet. + * 배치 재실행 시 중복 데이터 적재를 방지한다. + * 흐름: Job 파라미터(requestDate) → yearWeek 계산 → DELETE + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class ClearWeeklyRankingTasklet implements Tasklet { + + private final MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + // Job 파라미터에서 실행 기준 날짜를 주입받는다 + // @StepScope가 있어야 #{jobParameters[...]} late binding이 동작한다 + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String yearWeek = computeYearWeek(requestDate); + log.info("주간 랭킹 MV 초기화: yearWeek={}", yearWeek); + + weeklyJpaRepository.deleteByYearWeek(yearWeek); + + return RepeatStatus.FINISHED; + } + + /** + * "yyyyMMdd" 형식의 날짜에서 ISO 주차를 계산한다. + * 예: "20260412" → 2026년 15주차 → "2026W15" + */ + public static String computeYearWeek(String dateStr) { + LocalDate date = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + int year = date.get(WeekFields.ISO.weekBasedYear()); + int week = date.get(WeekFields.ISO.weekOfWeekBasedYear()); + return String.format("%dW%02d", year, week); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java new file mode 100644 index 0000000000..2eea4f2fbc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankMonthlyModel.java @@ -0,0 +1,50 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "mv_product_rank_monthly", indexes = { + @Index(name = "idx_mv_monthly_year_month_ranking", columnList = "`year_month`, `ranking`") +}) +@Getter +public class MvProductRankMonthlyModel extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; + + @Column(name = "`ranking`", nullable = false) + private int ranking; + + @Column(name = "`year_month`", nullable = false, length = 10) + private String yearMonth; // e.g., "202604" + + protected MvProductRankMonthlyModel() {} + + public MvProductRankMonthlyModel(Long productId, long viewCount, long likeCount, + long salesCount, double score, int ranking, String yearMonth) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.ranking = ranking; + this.yearMonth = yearMonth; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java new file mode 100644 index 0000000000..462a7f3bfd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/ranking/MvProductRankWeeklyModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.ranking; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; + +/** + * 주간 TOP 100 랭킹 Materialized View. + * 배치 Job이 product_metrics에서 가중치 점수를 계산하여 적재한다. + * 흐름: product_metrics → Batch(Reader/Processor/Writer) → 이 테이블 → API 조회 + */ +@Entity +@Table(name = "mv_product_rank_weekly", indexes = { + @Index(name = "idx_mv_weekly_year_week_ranking", columnList = "year_week, `ranking`") +}) +@Getter +public class MvProductRankWeeklyModel extends BaseEntity { + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private long viewCount; + + @Column(nullable = false) + private long likeCount; + + @Column(nullable = false) + private long salesCount; + + @Column(nullable = false) + private double score; // viewCount * 0.1 + likeCount * 0.2 + salesCount * 0.7 + + @Column(name = "`ranking`", nullable = false) + private int ranking; // 1-based 순위 + + @Column(name = "year_week", nullable = false, length = 10) + private String yearWeek; // e.g., "2026W15" + + protected MvProductRankWeeklyModel() {} + + public MvProductRankWeeklyModel(Long productId, long viewCount, long likeCount, + long salesCount, double score, int ranking, String yearWeek) { + this.productId = productId; + this.viewCount = viewCount; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.score = score; + this.ranking = ranking; + this.yearWeek = yearWeek; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java new file mode 100644 index 0000000000..839c248ba7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankMonthlyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankMonthlyJpaRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MvProductRankMonthlyModel m WHERE m.yearMonth = :yearMonth") + void deleteByYearMonth(@Param("yearMonth") String yearMonth); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java new file mode 100644 index 0000000000..12c6c206e0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.ranking; + +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MvProductRankWeeklyJpaRepository extends JpaRepository { + + /** 특정 주차의 기존 랭킹 데이터를 일괄 삭제 (배치 재실행 대비) */ + @Modifying(clearAutomatically = true) + @Query("DELETE FROM MvProductRankWeeklyModel m WHERE m.yearWeek = :yearWeek") + void deleteByYearWeek(@Param("yearWeek") String yearWeek); +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a35..71a9071861 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@TestPropertySource(properties = "spring.batch.job.enabled=false") public class CommerceBatchApplicationTest { @Test void contextLoads() {} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java deleted file mode 100644 index dafe59a18c..0000000000 --- a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.loopers.job.demo; - -import com.loopers.batch.job.demo.DemoJobConfig; -import lombok.RequiredArgsConstructor; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.test.JobLauncherTestUtils; -import org.springframework.batch.test.context.SpringBatchTest; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestPropertySource; - -import java.time.LocalDate; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -@SpringBootTest -@SpringBatchTest -@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) -class DemoJobE2ETest { - - // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. - // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. - @Autowired - private JobLauncherTestUtils jobLauncherTestUtils; - - @Autowired - @Qualifier(DemoJobConfig.JOB_NAME) - private Job job; - - @BeforeEach - void beforeEach() { - - } - - @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") - @Test - void shouldNotSaveCategories_whenApiError() throws Exception { - // arrange - jobLauncherTestUtils.setJob(job); - - // act - var jobExecution = jobLauncherTestUtils.launchJob(); - - // assert - assertAll( - () -> assertThat(jobExecution).isNotNull(), - () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) - ); - } - - @DisplayName("demoJob 배치가 정상적으로 실행된다.") - @Test - void success() throws Exception { - // arrange - jobLauncherTestUtils.setJob(job); - - // act - var jobParameters = new JobParametersBuilder() - .addLocalDate("requestDate", LocalDate.now()) - .toJobParameters(); - var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); - - // assert - assertAll( - () -> assertThat(jobExecution).isNotNull(), - () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) - ); - } -} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java new file mode 100644 index 0000000000..09f03e0833 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java @@ -0,0 +1,128 @@ +package com.loopers.job.ranking; + +import com.loopers.batch.job.ranking.weekly.WeeklyRankingJobConfig; +import com.loopers.domain.ranking.MvProductRankWeeklyModel; +import com.loopers.infrastructure.ranking.MvProductRankWeeklyJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import org.junit.jupiter.api.BeforeEach; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + WeeklyRankingJobConfig.JOB_NAME) +class WeeklyRankingJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(WeeklyRankingJobConfig.JOB_NAME) + private org.springframework.batch.core.Job job; + + @Autowired + private MvProductRankWeeklyJpaRepository weeklyJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // commerce-batch에는 ProductMetricsModel 엔티티가 없어 Hibernate가 테이블을 생성하지 않으므로 직접 생성 + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS product_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + product_id BIGINT NOT NULL UNIQUE, + view_count BIGINT NOT NULL DEFAULT 0, + like_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL + ) ENGINE=InnoDB + """); + jdbcTemplate.execute("DELETE FROM product_metrics"); + weeklyJpaRepository.deleteAll(); + } + + @DisplayName("product_metrics 데이터가 있으면 주간 랭킹 MV가 정상 적재된다") + @Test + void weeklyRankingJob_success() throws Exception { + // given — product_metrics에 테스트 데이터 삽입 + jdbcTemplate.execute(""" + INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, sales_amount, version, updated_at) + VALUES + (1, 100, 50, 10, 1000000, 0, NOW()), + (2, 200, 30, 5, 500000, 0, NOW()), + (3, 50, 100, 20, 2000000, 0, NOW()) + """); + + jobLauncherTestUtils.setJob(job); + + // when — 배치 실행 + var jobParameters = new JobParametersBuilder() + .addString("requestDate", "20260412") + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // then — Job 성공 확인 + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // then — MV 데이터 검증 + List results = weeklyJpaRepository.findAll(); + assertAll( + () -> assertThat(results).hasSize(3), + () -> assertThat(results.get(0).getRanking()).isEqualTo(1), + () -> assertThat(results.get(0).getScore()) + .isGreaterThan(results.get(1).getScore()), // 1등 점수 > 2등 점수 + () -> assertThat(results.get(0).getYearWeek()).isEqualTo("2026W15") + ); + } + + @DisplayName("배치 재실행 시 기존 데이터가 삭제되고 새로 적재된다") + @Test + void weeklyRankingJob_rerun_replacesOldData() throws Exception { + // given — product_metrics 데이터 삽입 + jdbcTemplate.execute(""" + INSERT INTO product_metrics (product_id, view_count, like_count, sales_count, sales_amount, version, updated_at) + VALUES (1, 100, 50, 10, 1000000, 0, NOW()) + """); + + jobLauncherTestUtils.setJob(job); + + var jobParameters = new JobParametersBuilder() + .addString("requestDate", "20260412") + .addLong("run.id", 10L) + .toJobParameters(); + + // when — 1차 실행 + jobLauncherTestUtils.launchJob(jobParameters); + + // when — 2차 실행 (동일 requestDate) + var jobParameters2 = new JobParametersBuilder() + .addString("requestDate", "20260412") + .addLong("run.id", 11L) + .toJobParameters(); + var jobExecution2 = jobLauncherTestUtils.launchJob(jobParameters2); + + // then — 중복 없이 1건만 존재 + assertThat(jobExecution2.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + assertThat(weeklyJpaRepository.findAll()).hasSize(1); + } +}