diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 9f57f5ffa..6307fbc7d 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -3,6 +3,7 @@ dependencies { implementation(project(":modules:jpa")) implementation(project(":modules:redis")) implementation(project(":modules:kafka")) + implementation(project(":modules:batch")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..003be297f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/MonthlyRankingJobConfig.java @@ -0,0 +1,202 @@ +package com.loopers.application.batch; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import com.loopers.application.batch.processor.RankingScoreProcessor; +import com.loopers.application.batch.writer.InMemoryRankingCollector; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +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.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 월간 랭킹 집계 배치 Job (Chunk-Oriented Processing) + * - Reader: JdbcPagingItemReader로 ProductMetrics 조회 + * - Processor: 점수 계산 + * - Writer: 메모리에 수집 + * - Listener: Step 완료 후 정렬하여 TOP 100 저장 + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class MonthlyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final MonthlyRankingRepository monthlyRankingRepository; + + private static final int CHUNK_SIZE = 100; + private static final int PAGE_SIZE = 100; + private static final int TOP_N = 100; + + @Value("${ranking.weight.view:0.1}") + private double viewWeight; + + @Value("${ranking.weight.like:0.2}") + private double likeWeight; + + @Value("${ranking.weight.order:0.6}") + private double orderWeight; + + @Bean + public Job monthlyRankingJob() { + return new JobBuilder("monthlyRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyRankingStep()) + .build(); + } + + @Bean + public Step monthlyRankingStep() { + InMemoryRankingCollector collector = new InMemoryRankingCollector(); + + return new StepBuilder("monthlyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(monthlyRankingReader()) + .processor(new RankingScoreProcessor(viewWeight, likeWeight, orderWeight)) + .writer(collector) + .listener(monthlyRankingStepListener(collector)) + .build(); + } + + /** + * Reader: ProductMetrics 테이블을 페이징으로 읽기 + */ + private JdbcPagingItemReader monthlyRankingReader() { + JdbcPagingItemReader reader = new JdbcPagingItemReader<>(); + reader.setDataSource(dataSource); + reader.setPageSize(PAGE_SIZE); + reader.setRowMapper(productMetricsRowMapper()); + + // MySQL PagingQueryProvider + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("SELECT product_id, like_count, view_count, sales_count, sales_amount"); + queryProvider.setFromClause("FROM product_metrics"); + queryProvider.setSortKeys(Map.of("product_id", Order.ASCENDING)); // 정렬 기준 (페이징을 위해 필요) + + reader.setQueryProvider(queryProvider); + reader.setName("monthlyRankingReader"); + + // IMPORTANT: Reader 초기화 필수 + try { + reader.afterPropertiesSet(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize monthlyRankingReader", e); + } + + return reader; + } + + /** + * RowMapper: ResultSet을 ProductMetricsDto로 변환 + */ + private RowMapper productMetricsRowMapper() { + return (rs, rowNum) -> new ProductMetricsDto( + rs.getLong("product_id"), + rs.getLong("like_count"), + rs.getLong("view_count"), + rs.getLong("sales_count"), + rs.getLong("sales_amount") + ); + } + + /** + * StepExecutionListener: Step 완료 후 정렬 및 TOP 100 저장 + */ + private StepExecutionListener monthlyRankingStepListener(InMemoryRankingCollector collector) { + return new StepExecutionListener() { + + @Override + public void beforeStep(StepExecution stepExecution) { + // Collector 초기화 + collector.clear(); + log.info("[MonthlyRanking] Step 시작 - Collector 초기화 완료"); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + String targetDateParam = stepExecution.getJobParameters() + .getString("targetDate"); + + if (targetDateParam == null) { + log.error("[MonthlyRanking] targetDate 파라미터가 없습니다"); + return ExitStatus.FAILED; + } + + LocalDate targetDate = LocalDate.parse(targetDateParam); + PeriodUtils.MonthRange monthRange = PeriodUtils.MonthRange.from(targetDate); + + log.info("[MonthlyRanking] Step 완료 - 월간: {} ({} ~ {})", + monthRange.key(), monthRange.start(), monthRange.end()); + + // 1. 기존 월간 랭킹 데이터 삭제 + monthlyRankingRepository.deleteByMonthYear(monthRange.key()); + log.info("[MonthlyRanking] 기존 월간 랭킹 데이터 삭제 완료: {}", monthRange.key()); + + // 2. 수집된 데이터 가져오기 + List collectedItems = collector.getCollectedItems(); + log.info("[MonthlyRanking] 총 {} 개 상품 메트릭 수집 완료", collectedItems.size()); + + if (collectedItems.isEmpty()) { + log.warn("[MonthlyRanking] 집계할 데이터가 없습니다"); + return ExitStatus.COMPLETED; + } + + // 3. 정렬 (점수 내림차순) + Collections.sort(collectedItems); + + // 4. TOP 100만 선택 + List topRankings = collectedItems.stream() + .limit(TOP_N) + .toList(); + + log.info("[MonthlyRanking] TOP {} 선택 완료", topRankings.size()); + + // 5. 순위 설정 및 저장 + AtomicInteger rank = new AtomicInteger(1); + List rankedList = topRankings.stream() + .map(dto -> new MonthlyRanking( + rank.getAndIncrement(), + dto.productId(), + dto.totalScore(), + monthRange.key(), + monthRange.start(), + monthRange.end() + )) + .toList(); + + monthlyRankingRepository.saveAll(rankedList); + + log.info("[MonthlyRanking] 월간 랭킹 집계 완료 - {} 건 저장", rankedList.size()); + + return ExitStatus.COMPLETED; + } + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..1d300012c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/WeeklyRankingJobConfig.java @@ -0,0 +1,201 @@ +package com.loopers.application.batch; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import com.loopers.application.batch.processor.RankingScoreProcessor; +import com.loopers.application.batch.writer.InMemoryRankingCollector; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +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.database.JdbcPagingItemReader; +import org.springframework.batch.item.database.Order; +import org.springframework.batch.item.database.support.MySqlPagingQueryProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 주간 랭킹 집계 배치 Job (Chunk-Oriented Processing) + * - Reader: JdbcPagingItemReader로 ProductMetrics 조회 + * - Processor: 점수 계산 + * - Writer: 메모리에 수집 + * - Listener: Step 완료 후 정렬하여 TOP 100 저장 + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class WeeklyRankingJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final DataSource dataSource; + private final WeeklyRankingRepository weeklyRankingRepository; + + private static final int CHUNK_SIZE = 100; + private static final int PAGE_SIZE = 100; + private static final int TOP_N = 100; + + @Value("${ranking.weight.view:0.1}") + private double viewWeight; + + @Value("${ranking.weight.like:0.2}") + private double likeWeight; + + @Value("${ranking.weight.order:0.6}") + private double orderWeight; + + @Bean + public Job weeklyRankingJob() { + return new JobBuilder("weeklyRankingJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(weeklyRankingStep()) + .build(); + } + + @Bean + public Step weeklyRankingStep() { + InMemoryRankingCollector collector = new InMemoryRankingCollector(); + + return new StepBuilder("weeklyRankingStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(weeklyRankingReader()) + .processor(new RankingScoreProcessor(viewWeight, likeWeight, orderWeight)) + .writer(collector) + .listener(weeklyRankingStepListener(collector)) + .build(); + } + + /** + * Reader: ProductMetrics 테이블을 페이징으로 읽기 + */ + private JdbcPagingItemReader weeklyRankingReader() { + JdbcPagingItemReader reader = new JdbcPagingItemReader<>(); + reader.setDataSource(dataSource); + reader.setPageSize(PAGE_SIZE); + reader.setRowMapper(productMetricsRowMapper()); + + // MySQL PagingQueryProvider + MySqlPagingQueryProvider queryProvider = new MySqlPagingQueryProvider(); + queryProvider.setSelectClause("SELECT product_id, like_count, view_count, sales_count, sales_amount"); + queryProvider.setFromClause("FROM product_metrics"); + queryProvider.setSortKeys(Map.of("product_id", Order.ASCENDING)); // 정렬 기준 (페이징을 위해 필요) + + reader.setQueryProvider(queryProvider); + reader.setName("weeklyRankingReader"); + + // IMPORTANT: Reader 초기화 필수 + try { + reader.afterPropertiesSet(); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize weeklyRankingReader", e); + } + + return reader; + } + + /** + * RowMapper: ResultSet을 ProductMetricsDto로 변환 + */ + private RowMapper productMetricsRowMapper() { + return (rs, rowNum) -> new ProductMetricsDto( + rs.getLong("product_id"), + rs.getLong("like_count"), + rs.getLong("view_count"), + rs.getLong("sales_count"), + rs.getLong("sales_amount") + ); + } + + /** + * StepExecutionListener: Step 완료 후 정렬 및 TOP 100 저장 + */ + private StepExecutionListener weeklyRankingStepListener(InMemoryRankingCollector collector) { + return new StepExecutionListener() { + + @Override + public void beforeStep(StepExecution stepExecution) { + // Collector 초기화 + collector.clear(); + log.info("[WeeklyRanking] Step 시작 - Collector 초기화 완료"); + } + + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + String targetDateParam = stepExecution.getJobParameters() + .getString("targetDate"); + + if (targetDateParam == null) { + log.error("[WeeklyRanking] targetDate 파라미터가 없습니다"); + return ExitStatus.FAILED; + } + + LocalDate targetDate = LocalDate.parse(targetDateParam); + LocalDate weekStartDate = PeriodUtils.getWeekStartDate(targetDate); + LocalDate weekEndDate = PeriodUtils.getWeekEndDate(targetDate); + + log.info("[WeeklyRanking] Step 완료 - 주간: {} ~ {}", weekStartDate, weekEndDate); + + // 1. 기존 주간 랭킹 데이터 삭제 + weeklyRankingRepository.deleteByWeekStartDate(weekStartDate); + log.info("[WeeklyRanking] 기존 주간 랭킹 데이터 삭제 완료: {}", weekStartDate); + + // 2. 수집된 데이터 가져오기 + List collectedItems = collector.getCollectedItems(); + log.info("[WeeklyRanking] 총 {} 개 상품 메트릭 수집 완료", collectedItems.size()); + + if (collectedItems.isEmpty()) { + log.warn("[WeeklyRanking] 집계할 데이터가 없습니다"); + return ExitStatus.COMPLETED; + } + + // 3. 정렬 (점수 내림차순) + Collections.sort(collectedItems); + + // 4. TOP 100만 선택 + List topRankings = collectedItems.stream() + .limit(TOP_N) + .toList(); + + log.info("[WeeklyRanking] TOP {} 선택 완료", topRankings.size()); + + // 5. 순위 설정 및 저장 + AtomicInteger rank = new AtomicInteger(1); + List rankedList = topRankings.stream() + .map(dto -> new WeeklyRanking( + rank.getAndIncrement(), + dto.productId(), + dto.totalScore(), + weekStartDate, + weekEndDate + )) + .toList(); + + weeklyRankingRepository.saveAll(rankedList); + + log.info("[WeeklyRanking] 주간 랭킹 집계 완료 - {} 건 저장", rankedList.size()); + + return ExitStatus.COMPLETED; + } + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java new file mode 100644 index 000000000..272d4ac68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/ProductMetricsDto.java @@ -0,0 +1,15 @@ +package com.loopers.application.batch.dto; + +/** + * Batch 처리용 ProductMetrics DTO + * - JdbcItemReader가 읽어온 데이터를 담는 객체 + * - primitive long 타입 사용으로 NPE 방지 (DB NULL은 0으로 변환됨) + */ +public record ProductMetricsDto( + long productId, + long likeCount, + long viewCount, + long salesCount, + long salesAmount +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java new file mode 100644 index 000000000..d071c3bc3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/dto/RankedProductDto.java @@ -0,0 +1,22 @@ +package com.loopers.application.batch.dto; + +/** + * 점수가 계산된 상품 정보 + * - Processor가 생성하는 중간 데이터 + * - primitive 타입 사용으로 NPE 방지 + */ +public record RankedProductDto( + long productId, + double totalScore, + long likeCount, + long viewCount, + long salesCount, + long salesAmount +) implements Comparable { + + @Override + public int compareTo(RankedProductDto other) { + // 점수 내림차순 정렬 + return Double.compare(other.totalScore, this.totalScore); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java new file mode 100644 index 000000000..9b58080cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/processor/RankingScoreProcessor.java @@ -0,0 +1,57 @@ +package com.loopers.application.batch.processor; + +import com.loopers.application.batch.dto.ProductMetricsDto; +import com.loopers.application.batch.dto.RankedProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * ProductMetrics를 읽어서 점수를 계산하는 Processor + */ +@Slf4j +public class RankingScoreProcessor implements ItemProcessor { + + private final double viewWeight; + private final double likeWeight; + private final double orderWeight; + + /** + * 생성자 주입으로 weight 값 받기 + */ + public RankingScoreProcessor(double viewWeight, double likeWeight, double orderWeight) { + this.viewWeight = viewWeight; + this.likeWeight = likeWeight; + this.orderWeight = orderWeight; + } + + @Override + public RankedProductDto process(ProductMetricsDto item) { + double totalScore = calculateTotalScore( + item.viewCount(), + item.likeCount(), + item.salesAmount() + ); + + return new RankedProductDto( + item.productId(), + totalScore, + item.likeCount(), + item.viewCount(), + item.salesCount(), + item.salesAmount() + ); + } + + /** + * 총 점수 계산 (기존 Redis 방식과 동일한 가중치) + */ + private double calculateTotalScore(long viewCount, long likeCount, long salesAmount) { + double viewScore = viewCount * viewWeight; + double likeScore = likeCount * likeWeight; + + // 주문 점수: log 정규화 적용 (고가 상품 편중 방지) + double orderScore = orderWeight * Math.log1p(salesAmount); + + return viewScore + likeScore + orderScore; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java b/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java new file mode 100644 index 000000000..1c6253ef4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/batch/writer/InMemoryRankingCollector.java @@ -0,0 +1,42 @@ +package com.loopers.application.batch.writer; + +import com.loopers.application.batch.dto.RankedProductDto; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Chunk 처리 중 RankedProduct를 메모리에 수집하는 Writer + * - Step 완료 후 정렬하여 TOP 100을 선택하기 위함 + */ +@Slf4j +public class InMemoryRankingCollector implements ItemWriter { + + @Getter + private final List collectedItems = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void write(Chunk chunk) { + collectedItems.addAll(chunk.getItems()); + log.debug("[Collector] Collected {} items, total: {}", chunk.size(), collectedItems.size()); + } + + /** + * 수집된 데이터 초기화 (다음 실행을 위해) + */ + public void clear() { + collectedItems.clear(); + } + + /** + * 수집된 데이터 개수 + */ + public int size() { + return collectedItems.size(); + } +} 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 c8dfc8226..a66e1e282 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 @@ -6,6 +6,9 @@ import com.loopers.domain.brand.repository.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.ranking.PeriodType; +import com.loopers.domain.ranking.PeriodUtils; +import com.loopers.domain.ranking.RankingService; import com.loopers.infrastructure.cache.ProductRankingCache; import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry; import lombok.RequiredArgsConstructor; @@ -33,17 +36,34 @@ public class RankingFacade { private final ProductRankingCache productRankingCache; private final ProductRepository productRepository; private final BrandRepository brandRepository; + private final RankingService rankingService; /** @param page 0-based */ public RankingPageInfo getRankings(String date, int page, int size) { - // 날짜 검증 및 기본값 처리 - String targetDate = validateAndNormalizeDate(date); + return getRankings(date, PeriodType.DAILY, page, size); + } + + /** @param page 0-based */ + public RankingPageInfo getRankings(String date, PeriodType periodType, int page, int size) { + // 날짜 검증 및 변환 + LocalDate targetDate = parseAndValidateDate(date); + + return switch (periodType) { + case DAILY -> getDailyRankings(targetDate, page, size); + case WEEKLY -> rankingService.getWeeklyRankings(targetDate, page, size); + case MONTHLY -> rankingService.getMonthlyRankings(targetDate, page, size); + }; + } + + /** @param page 0-based */ + private RankingPageInfo getDailyRankings(LocalDate targetDate, int page, int size) { + String dateString = targetDate.format(DATE_FORMATTER); // 1. ZSET에서 랭킹 조회 - List rankingEntries = productRankingCache.getTopRankings(targetDate, page, size); + List rankingEntries = productRankingCache.getTopRankings(dateString, page, size); if (rankingEntries.isEmpty()) { - return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0); + return RankingPageInfo.of(Collections.emptyList(), dateString, page, size, 0); } // 2. 상품 ID 목록 추출 @@ -90,11 +110,24 @@ public RankingPageInfo getRankings(String date, int page, int size) { .toList(); // 6. 전체 개수 조회 - long totalCount = productRankingCache.getTotalCount(targetDate); + long totalCount = productRankingCache.getTotalCount(dateString); - return RankingPageInfo.of(rankings, targetDate, page, size, totalCount); + return RankingPageInfo.of(rankings, dateString, page, size, totalCount); } + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ + private LocalDate parseAndValidateDate(String date) { + if (date == null || date.isBlank()) { + return LocalDate.now(); + } + + try { + return LocalDate.parse(date, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date); + } + } + /** @throws IllegalArgumentException 유효하지 않은 날짜 형식 */ private String validateAndNormalizeDate(String date) { if (date == null || date.isBlank()) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java new file mode 100644 index 000000000..df70e8f5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.ranking; + +public enum PeriodType { + DAILY, + WEEKLY, + MONTHLY +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java new file mode 100644 index 000000000..c6841e20c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/PeriodUtils.java @@ -0,0 +1,75 @@ +package com.loopers.domain.ranking; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; + +public class PeriodUtils { + + private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + /** + * 주어진 날짜가 속한 주의 시작일(월요일) 반환 + */ + public static LocalDate getWeekStartDate(LocalDate date) { + return date.with(DayOfWeek.MONDAY); + } + + /** + * 주어진 날짜가 속한 주의 종료일(일요일) 반환 + */ + public static LocalDate getWeekEndDate(LocalDate date) { + return date.with(DayOfWeek.MONDAY).plusDays(6); + } + + /** + * 주어진 날짜가 속한 월의 시작일 반환 + */ + public static LocalDate getMonthStartDate(LocalDate date) { + return YearMonth.from(date).atDay(1); + } + + /** + * 주어진 날짜가 속한 월의 마지막일 반환 + */ + public static LocalDate getMonthEndDate(LocalDate date) { + return YearMonth.from(date).atEndOfMonth(); + } + + /** + * 월간 키 생성 (YYYY-MM 형태) + */ + public static String getMonthKey(LocalDate date) { + return YearMonth.from(date).format(MONTH_FORMATTER); + } + + /** + * 문자열 날짜(yyyyMMdd)를 LocalDate로 변환 + */ + public static LocalDate parseDate(String dateString) { + return LocalDate.parse(dateString, DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + /** + * 주간 범위 정보 + */ + public record WeekRange(LocalDate start, LocalDate end) { + public static WeekRange from(LocalDate date) { + return new WeekRange(getWeekStartDate(date), getWeekEndDate(date)); + } + } + + /** + * 월간 범위 정보 + */ + public record MonthRange(LocalDate start, LocalDate end, String key) { + public static MonthRange from(LocalDate date) { + return new MonthRange( + getMonthStartDate(date), + getMonthEndDate(date), + getMonthKey(date) + ); + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..6faf64014 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java @@ -0,0 +1,187 @@ +package com.loopers.domain.ranking; + +import com.loopers.application.ranking.RankingInfo.RankingItemInfo; +import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.repository.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.repository.ProductRepository; +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RankingService { + + private final WeeklyRankingRepository weeklyRankingRepository; + private final MonthlyRankingRepository monthlyRankingRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + /** + * 주간 랭킹 조회 + */ + public RankingPageInfo getWeeklyRankings(LocalDate targetDate, int page, int size) { + LocalDate weekStartDate = PeriodUtils.getWeekStartDate(targetDate); + + // 1. 주간 랭킹 데이터 조회 + List weeklyRankings = weeklyRankingRepository + .findByWeekStartDateWithPagination(weekStartDate, page * size, size); + + if (weeklyRankings.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, 0); + } + + // 2. 상품 및 브랜드 정보 조회 + List rankingItems = buildRankingItemsFromWeekly(weeklyRankings); + + // 3. 전체 개수 조회 + List allRankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + long totalCount = allRankings.size(); + + return RankingPageInfo.of(rankingItems, + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, totalCount); + } + + /** + * 월간 랭킹 조회 + */ + public RankingPageInfo getMonthlyRankings(LocalDate targetDate, int page, int size) { + String monthKey = PeriodUtils.getMonthKey(targetDate); + + // 1. 월간 랭킹 데이터 조회 + List monthlyRankings = monthlyRankingRepository + .findByMonthYearWithPagination(monthKey, page * size, size); + + if (monthlyRankings.isEmpty()) { + return RankingPageInfo.of(Collections.emptyList(), + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, 0); + } + + // 2. 상품 및 브랜드 정보 조회 + List rankingItems = buildRankingItemsFromMonthly(monthlyRankings); + + // 3. 전체 개수 조회 + List allRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + long totalCount = allRankings.size(); + + return RankingPageInfo.of(rankingItems, + targetDate.format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")), + page, size, totalCount); + } + + /** + * WeeklyRanking을 RankingItemInfo로 변환 + */ + private List buildRankingItemsFromWeekly(List weeklyRankings) { + // 1. 상품 ID 목록 추출 + List productIds = weeklyRankings.stream() + .map(WeeklyRanking::getProductId) + .toList(); + + // 2. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 3. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 4. 응답 생성 + return weeklyRankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.getProductId()); + if (product == null) { + log.warn("[WeeklyRanking] Product not found - productId: {}", ranking.getProductId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + ranking.getRankPosition(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + ranking.getTotalScore() + ); + }) + .filter(item -> item != null) + .toList(); + } + + /** + * MonthlyRanking을 RankingItemInfo로 변환 + */ + private List buildRankingItemsFromMonthly(List monthlyRankings) { + // 1. 상품 ID 목록 추출 + List productIds = monthlyRankings.stream() + .map(MonthlyRanking::getProductId) + .toList(); + + // 2. 상품 정보 조회 + List products = productRepository.findByIdIn(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + // 3. 브랜드 정보 조회 (N+1 방지) + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + List brands = brandRepository.findByIdIn(brandIds); + Map brandMap = brands.stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + // 4. 응답 생성 + return monthlyRankings.stream() + .map(ranking -> { + Product product = productMap.get(ranking.getProductId()); + if (product == null) { + log.warn("[MonthlyRanking] Product not found - productId: {}", ranking.getProductId()); + return null; + } + Brand brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : "Unknown"; + + return new RankingItemInfo( + ranking.getRankPosition(), + product.getId(), + product.getName(), + brandName, + product.getPrice(), + product.getLikeCount(), + ranking.getTotalScore() + ); + }) + .filter(item -> item != null) + .toList(); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java new file mode 100644 index 000000000..c55294ae1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRanking.java @@ -0,0 +1,56 @@ +package com.loopers.domain.ranking.monthly; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_monthly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_monthly_ranking_month_position", + columnNames = {"month_start_date", "rank_position"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "month_year", nullable = false) + private String monthYear; + + @Column(name = "month_start_date", nullable = false) + private LocalDate monthStartDate; + + @Column(name = "month_end_date", nullable = false) + private LocalDate monthEndDate; + + public MonthlyRanking(Integer rankPosition, Long productId, Double totalScore, + String monthYear, LocalDate monthStartDate, LocalDate monthEndDate) { + this.rankPosition = rankPosition; + this.productId = productId; + this.totalScore = totalScore; + this.monthYear = monthYear; + this.monthStartDate = monthStartDate; + this.monthEndDate = monthEndDate; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java new file mode 100644 index 000000000..f028fbed8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/monthly/MonthlyRankingRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.ranking.monthly; + +import java.util.List; + +public interface MonthlyRankingRepository { + + List findByMonthYearOrderByRankPosition(String monthYear); + + List findByMonthYearWithPagination(String monthYear, int offset, int limit); + + void deleteByMonthYear(String monthYear); + + void saveAll(List rankings); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java new file mode 100644 index 000000000..8be2d1751 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRanking.java @@ -0,0 +1,52 @@ +package com.loopers.domain.ranking.weekly; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table( + name = "mv_product_rank_weekly", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_weekly_ranking_week_position", + columnNames = {"week_start_date", "rank_position"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "total_score", nullable = false) + private Double totalScore; + + @Column(name = "week_start_date", nullable = false) + private LocalDate weekStartDate; + + @Column(name = "week_end_date", nullable = false) + private LocalDate weekEndDate; + + public WeeklyRanking(Integer rankPosition, Long productId, Double totalScore, + LocalDate weekStartDate, LocalDate weekEndDate) { + this.rankPosition = rankPosition; + this.productId = productId; + this.totalScore = totalScore; + this.weekStartDate = weekStartDate; + this.weekEndDate = weekEndDate; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java new file mode 100644 index 000000000..1e96989d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ranking/weekly/WeeklyRankingRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.ranking.weekly; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingRepository { + + List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate); + + List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit); + + void deleteByWeekStartDate(LocalDate weekStartDate); + + void saveAll(List rankings); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java new file mode 100644 index 000000000..a82a3c964 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.ranking.monthly; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface MonthlyRankingJpaRepository extends JpaRepository { + + List findByMonthYearOrderByRankPosition(String monthYear); + + @Query("SELECT m FROM MonthlyRanking m WHERE m.monthYear = :monthYear " + + "ORDER BY m.rankPosition LIMIT :limit OFFSET :offset") + List findByMonthYearWithPagination(String monthYear, int offset, int limit); + + @Modifying + @Transactional + void deleteByMonthYear(String monthYear); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java new file mode 100644 index 000000000..d49c53d16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/monthly/MonthlyRankingRepositoryImpl.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.ranking.monthly; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.loopers.domain.ranking.monthly.QMonthlyRanking.monthlyRanking; + +@Repository +@RequiredArgsConstructor +public class MonthlyRankingRepositoryImpl implements MonthlyRankingRepository { + + private final MonthlyRankingJpaRepository jpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public List findByMonthYearOrderByRankPosition(String monthYear) { + return queryFactory + .selectFrom(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .orderBy(monthlyRanking.rankPosition.asc()) + .fetch(); + } + + @Override + public List findByMonthYearWithPagination(String monthYear, int offset, int limit) { + return queryFactory + .selectFrom(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .orderBy(monthlyRanking.rankPosition.asc()) + .offset(offset) + .limit(limit) + .fetch(); + } + + @Override + @Transactional + public void deleteByMonthYear(String monthYear) { + queryFactory + .delete(monthlyRanking) + .where(monthlyRanking.monthYear.eq(monthYear)) + .execute(); + } + + @Override + @Transactional + public void saveAll(List rankings) { + jpaRepository.saveAll(rankings); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java new file mode 100644 index 000000000..fbd4f33d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.ranking.weekly; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +public interface WeeklyRankingJpaRepository extends JpaRepository { + + List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate); + + @Query("SELECT w FROM WeeklyRanking w WHERE w.weekStartDate = :weekStartDate " + + "ORDER BY w.rankPosition LIMIT :limit OFFSET :offset") + List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit); + + @Modifying + @Transactional + void deleteByWeekStartDate(LocalDate weekStartDate); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java new file mode 100644 index 000000000..927af53e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/weekly/WeeklyRankingRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.loopers.infrastructure.ranking.weekly; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static com.loopers.domain.ranking.weekly.QWeeklyRanking.weeklyRanking; + +@Repository +@RequiredArgsConstructor +public class WeeklyRankingRepositoryImpl implements WeeklyRankingRepository { + + private final WeeklyRankingJpaRepository jpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public List findByWeekStartDateOrderByRankPosition(LocalDate weekStartDate) { + return queryFactory + .selectFrom(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .orderBy(weeklyRanking.rankPosition.asc()) + .fetch(); + } + + @Override + public List findByWeekStartDateWithPagination(LocalDate weekStartDate, int offset, int limit) { + return queryFactory + .selectFrom(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .orderBy(weeklyRanking.rankPosition.asc()) + .offset(offset) + .limit(limit) + .fetch(); + } + + @Override + @Transactional + public void deleteByWeekStartDate(LocalDate weekStartDate) { + queryFactory + .delete(weeklyRanking) + .where(weeklyRanking.weekStartDate.eq(weekStartDate)) + .execute(); + } + + @Override + @Transactional + public void saveAll(List rankings) { + jpaRepository.saveAll(rankings); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java new file mode 100644 index 000000000..12f2aa6e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/batch/BatchJobController.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.batch; + +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/v1/admin/batch") +@RequiredArgsConstructor +@Slf4j +public class BatchJobController { + + private final JobLauncher jobLauncher; + private final Job weeklyRankingJob; + private final Job monthlyRankingJob; + + /** + * 주간 랭킹 집계 Job 실행 + */ + @PostMapping("/weekly-ranking") + public ApiResponse runWeeklyRankingJob( + @RequestParam(value = "targetDate", required = false) String targetDate + ) { + try { + String dateParam = targetDate != null ? targetDate : LocalDate.now().toString(); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", dateParam) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyRankingJob, jobParameters); + + log.info("주간 랭킹 집계 Job 실행 완료 - targetDate: {}", dateParam); + return ApiResponse.success("주간 랭킹 집계 Job이 성공적으로 실행되었습니다."); + + } catch (Exception e) { + log.error("주간 랭킹 집계 Job 실행 실패", e); + return ApiResponse.fail("BATCH_ERROR", "주간 랭킹 집계 Job 실행에 실패했습니다: " + e.getMessage()); + } + } + + /** + * 월간 랭킹 집계 Job 실행 + */ + @PostMapping("/monthly-ranking") + public ApiResponse runMonthlyRankingJob( + @RequestParam(value = "targetDate", required = false) String targetDate + ) { + try { + String dateParam = targetDate != null ? targetDate : LocalDate.now().toString(); + + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", dateParam) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + log.info("월간 랭킹 집계 Job 실행 완료 - targetDate: {}", dateParam); + return ApiResponse.success("월간 랭킹 집계 Job이 성공적으로 실행되었습니다."); + + } catch (Exception e) { + log.error("월간 랭킹 집계 Job 실행 실패", e); + return ApiResponse.fail("BATCH_ERROR", "월간 랭킹 집계 Job 실행에 실패했습니다: " + e.getMessage()); + } + } +} \ No newline at end of file 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 1e9b98610..3211bea36 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 @@ -2,6 +2,7 @@ import com.loopers.application.ranking.RankingFacade; import com.loopers.application.ranking.RankingInfo.RankingPageInfo; +import com.loopers.domain.ranking.PeriodType; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.ranking.RankingV1Dto.RankingPageResponse; import jakarta.validation.constraints.Max; @@ -19,20 +20,24 @@ public class RankingV1Controller { private final RankingFacade rankingFacade; /** - * 랭킹 페이지 조회 - * GET /api/v1/rankings?date=yyyyMMdd&size=20&page=1 + * 랭킹 페이지 조회 + * GET /api/v1/rankings?date=yyyyMMdd&period=DAILY&size=20&page=1 * + * @param date 조회 날짜 (yyyyMMdd 형태) + * @param period 조회 기간 (DAILY/WEEKLY/MONTHLY, 기본값 DAILY) * @param page 페이지 번호 (1-based, 기본값 1) + * @param size 페이지 크기 (기본값 20, 최대 100) */ @GetMapping("/rankings") public ApiResponse getRankings( @RequestParam(value = "date", required = false) String date, + @RequestParam(value = "period", defaultValue = "DAILY") PeriodType period, @RequestParam(value = "page", defaultValue = "1") @Min(1) int page, @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(100) int size ) { // API는 1-based, 내부는 0-based로 변환 int zeroBasedPage = Math.max(0, page - 1); - RankingPageInfo info = rankingFacade.getRankings(date, zeroBasedPage, size); + RankingPageInfo info = rankingFacade.getRankings(date, period, zeroBasedPage, size); RankingPageResponse response = RankingPageResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 2fd21d1c8..cb415c8e8 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -22,6 +22,7 @@ spring: - jpa.yml - redis.yml - kafka.yml + - batch.yml - logging.yml - monitoring.yml - resilience4j.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java new file mode 100644 index 000000000..359e19379 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/batch/MonthlyRankingJobTest.java @@ -0,0 +1,217 @@ +package com.loopers.application.batch; + +import com.loopers.domain.ranking.monthly.MonthlyRanking; +import com.loopers.domain.ranking.monthly.MonthlyRankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * MonthlyRankingJob 통합 테스트 + * - TestContainers로 MySQL 실행 + * - 샘플 ProductMetrics 데이터 생성 + * - 배치 실행 및 결과 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@Sql(scripts = "/db/init-ranking-tables.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class MonthlyRankingJobTest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job monthlyRankingJob; + + @Autowired + private MonthlyRankingRepository monthlyRankingRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // 기존 데이터 정리 + jdbcTemplate.execute("DELETE FROM mv_product_rank_monthly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @Test + @DisplayName("월간 랭킹 배치가 ProductMetrics를 읽어서 TOP 100 랭킹을 생성한다") + void shouldGenerateMonthlyRankingFromProductMetrics() throws Exception { + // Given: 샘플 ProductMetrics 데이터 생성 (150개) + insertSampleProductMetrics(150); + + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + LocalDate monthStartDate = LocalDate.of(2024, 12, 1); + LocalDate monthEndDate = LocalDate.of(2024, 12, 31); + + // When: 월간 랭킹 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) // 유니크 파라미터 + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 배치 실행 성공 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Then: TOP 100만 저장되었는지 확인 + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(100); + + // Then: 순위가 올바르게 설정되었는지 확인 (1위부터 100위까지) + for (int i = 0; i < 100; i++) { + MonthlyRanking ranking = rankings.get(i); + assertThat(ranking.getRankPosition()).isEqualTo(i + 1); + assertThat(ranking.getMonthYear()).isEqualTo(monthKey); + assertThat(ranking.getMonthStartDate()).isEqualTo(monthStartDate); + assertThat(ranking.getMonthEndDate()).isEqualTo(monthEndDate); + } + + // Then: 점수가 내림차순으로 정렬되었는지 확인 + for (int i = 0; i < rankings.size() - 1; i++) { + assertThat(rankings.get(i).getTotalScore()) + .isGreaterThanOrEqualTo(rankings.get(i + 1).getTotalScore()); + } + + // Then: 1위 상품이 가장 높은 점수를 가지는지 확인 + MonthlyRanking firstRank = rankings.get(0); + assertThat(firstRank.getRankPosition()).isEqualTo(1); + assertThat(firstRank.getTotalScore()).isGreaterThan(0); + } + + @Test + @DisplayName("ProductMetrics가 100개 미만일 때 모든 데이터를 랭킹에 포함한다") + void shouldIncludeAllDataWhenLessThan100() throws Exception { + // Given: 50개의 ProductMetrics 데이터 + insertSampleProductMetrics(50); + + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 50개 모두 저장 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(50); + } + + @Test + @DisplayName("기존 월간 랭킹 데이터가 있으면 삭제 후 새로 생성한다") + void shouldDeleteOldRankingBeforeCreatingNew() throws Exception { + // Given: 기존 월간 랭킹 데이터 생성 + LocalDate targetDate = LocalDate.of(2024, 12, 15); + String monthKey = "2024-12"; + LocalDate monthStartDate = LocalDate.of(2024, 12, 1); + LocalDate monthEndDate = LocalDate.of(2024, 12, 31); + + MonthlyRanking oldRanking = new MonthlyRanking( + 1, 999L, 100.0, monthKey, monthStartDate, monthEndDate + ); + monthlyRankingRepository.saveAll(List.of(oldRanking)); + + // Given: 새로운 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 기존 데이터는 삭제되고 새 데이터만 존재 + List rankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition(monthKey); + + assertThat(rankings).hasSize(10); + assertThat(rankings).noneMatch(r -> r.getProductId().equals(999L)); + } + + @Test + @DisplayName("다른 월의 랭킹 데이터는 영향을 받지 않는다") + void shouldNotAffectOtherMonthsRanking() throws Exception { + // Given: 2024-11월 랭킹 데이터 + MonthlyRanking nov2024Ranking = new MonthlyRanking( + 1, 888L, 200.0, "2024-11", + LocalDate.of(2024, 11, 1), + LocalDate.of(2024, 11, 30) + ); + monthlyRankingRepository.saveAll(List.of(nov2024Ranking)); + + // Given: 2024-12월 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 2024-12월 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", "2024-12-15") + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(monthlyRankingJob, jobParameters); + + // Then: 2024-11월 데이터는 그대로 유지 + List novRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition("2024-11"); + + assertThat(novRankings).hasSize(1); + assertThat(novRankings.get(0).getProductId()).isEqualTo(888L); + + // Then: 2024-12월 데이터는 새로 생성 + List decRankings = monthlyRankingRepository + .findByMonthYearOrderByRankPosition("2024-12"); + + assertThat(decRankings).hasSize(10); + } + + /** + * 샘플 ProductMetrics 데이터 생성 + * - productId: 1부터 count까지 + * - 점수가 다양하도록 랜덤하게 생성 + */ + private void insertSampleProductMetrics(int count) { + for (long i = 1; i <= count; i++) { + long likeCount = (count - i + 1) * 10; // 역순으로 점수 부여 + long viewCount = (count - i + 1) * 100; + long salesCount = (count - i + 1) * 5; + long salesAmount = (count - i + 1) * 50000; + + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, last_updated) " + + "VALUES (?, ?, ?, ?, ?, NOW())", + i, likeCount, viewCount, salesCount, salesAmount + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java b/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java new file mode 100644 index 000000000..0062d003e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/batch/WeeklyRankingJobTest.java @@ -0,0 +1,189 @@ +package com.loopers.application.batch; + +import com.loopers.domain.ranking.weekly.WeeklyRanking; +import com.loopers.domain.ranking.weekly.WeeklyRankingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WeeklyRankingJob 통합 테스트 + * - TestContainers로 MySQL 실행 + * - 샘플 ProductMetrics 데이터 생성 + * - 배치 실행 및 결과 검증 + */ +@SpringBootTest +@ActiveProfiles("test") +@Sql(scripts = "/db/init-ranking-tables.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class WeeklyRankingJobTest { + + @Autowired + private JobLauncher jobLauncher; + + @Autowired + private Job weeklyRankingJob; + + @Autowired + private WeeklyRankingRepository weeklyRankingRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + // 기존 데이터 정리 + jdbcTemplate.execute("DELETE FROM mv_product_rank_weekly"); + jdbcTemplate.execute("DELETE FROM product_metrics"); + } + + @Test + @DisplayName("주간 랭킹 배치가 ProductMetrics를 읽어서 TOP 100 랭킹을 생성한다") + void shouldGenerateWeeklyRankingFromProductMetrics() throws Exception { + // Given: 샘플 ProductMetrics 데이터 생성 (150개) + insertSampleProductMetrics(150); + + LocalDate targetDate = LocalDate.of(2024, 12, 30); // 월요일 + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); // 월요일 + LocalDate weekEndDate = LocalDate.of(2025, 1, 5); // 일요일 + + // When: 주간 랭킹 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) // 유니크 파라미터 + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 배치 실행 성공 + if (!jobExecution.getExitStatus().getExitCode().equals("COMPLETED")) { + System.out.println("=== Job Execution Failed ==="); + System.out.println("Exit Status: " + jobExecution.getExitStatus()); + System.out.println("Exit Description: " + jobExecution.getExitStatus().getExitDescription()); + jobExecution.getStepExecutions().forEach(step -> { + System.out.println("Step: " + step.getStepName()); + System.out.println(" Exit Status: " + step.getExitStatus()); + System.out.println(" Failures: " + step.getFailureExceptions()); + step.getFailureExceptions().forEach(Throwable::printStackTrace); + }); + } + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + // Then: TOP 100만 저장되었는지 확인 + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(100); + + // Then: 순위가 올바르게 설정되었는지 확인 (1위부터 100위까지) + for (int i = 0; i < 100; i++) { + WeeklyRanking ranking = rankings.get(i); + assertThat(ranking.getRankPosition()).isEqualTo(i + 1); + assertThat(ranking.getWeekStartDate()).isEqualTo(weekStartDate); + assertThat(ranking.getWeekEndDate()).isEqualTo(weekEndDate); + } + + // Then: 점수가 내림차순으로 정렬되었는지 확인 + for (int i = 0; i < rankings.size() - 1; i++) { + assertThat(rankings.get(i).getTotalScore()) + .isGreaterThanOrEqualTo(rankings.get(i + 1).getTotalScore()); + } + + // Then: 1위 상품이 가장 높은 점수를 가지는지 확인 + WeeklyRanking firstRank = rankings.get(0); + assertThat(firstRank.getRankPosition()).isEqualTo(1); + assertThat(firstRank.getTotalScore()).isGreaterThan(0); + } + + @Test + @DisplayName("ProductMetrics가 100개 미만일 때 모든 데이터를 랭킹에 포함한다") + void shouldIncludeAllDataWhenLessThan100() throws Exception { + // Given: 50개의 ProductMetrics 데이터 + insertSampleProductMetrics(50); + + LocalDate targetDate = LocalDate.of(2024, 12, 30); + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + JobExecution jobExecution = jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 50개 모두 저장 + assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED"); + + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(50); + } + + @Test + @DisplayName("기존 주간 랭킹 데이터가 있으면 삭제 후 새로 생성한다") + void shouldDeleteOldRankingBeforeCreatingNew() throws Exception { + // Given: 기존 주간 랭킹 데이터 생성 + LocalDate targetDate = LocalDate.of(2024, 12, 30); + LocalDate weekStartDate = LocalDate.of(2024, 12, 30); + LocalDate weekEndDate = LocalDate.of(2025, 1, 5); + + WeeklyRanking oldRanking = new WeeklyRanking( + 1, 999L, 100.0, weekStartDate, weekEndDate + ); + weeklyRankingRepository.saveAll(List.of(oldRanking)); + + // Given: 새로운 ProductMetrics 데이터 + insertSampleProductMetrics(10); + + // When: 배치 실행 + JobParameters jobParameters = new JobParametersBuilder() + .addString("targetDate", targetDate.toString()) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + jobLauncher.run(weeklyRankingJob, jobParameters); + + // Then: 기존 데이터는 삭제되고 새 데이터만 존재 + List rankings = weeklyRankingRepository + .findByWeekStartDateOrderByRankPosition(weekStartDate); + + assertThat(rankings).hasSize(10); + assertThat(rankings).noneMatch(r -> r.getProductId().equals(999L)); + } + + /** + * 샘플 ProductMetrics 데이터 생성 + * - productId: 1부터 count까지 + * - 점수가 다양하도록 랜덤하게 생성 + */ + private void insertSampleProductMetrics(int count) { + for (long i = 1; i <= count; i++) { + long likeCount = (count - i + 1) * 10; // 역순으로 점수 부여 + long viewCount = (count - i + 1) * 100; + long salesCount = (count - i + 1) * 5; + long salesAmount = (count - i + 1) * 50000; + + jdbcTemplate.update( + "INSERT INTO product_metrics (product_id, like_count, view_count, sales_count, sales_amount, last_updated) " + + "VALUES (?, ?, ?, ?, ?, NOW())", + i, likeCount, viewCount, salesCount, salesAmount + ); + } + } +} diff --git a/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql new file mode 100644 index 000000000..863e4e34c --- /dev/null +++ b/apps/commerce-api/src/test/resources/db/init-ranking-tables.sql @@ -0,0 +1,46 @@ +-- 주간 랭킹 Materialized View +CREATE TABLE IF NOT EXISTS mv_product_rank_weekly ( + id BIGINT AUTO_INCREMENT, + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + week_start_date DATE NOT NULL COMMENT '주간 시작일 (월요일)', + week_end_date DATE NOT NULL COMMENT '주간 종료일 (일요일)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY uk_weekly_ranking_week_position (week_start_date, rank_position), + INDEX idx_week_product (week_start_date, product_id), + INDEX idx_product_week (product_id, week_start_date) +) COMMENT '주간 상품 랭킹 (TOP 100)'; + +-- 월간 랭킹 Materialized View +CREATE TABLE IF NOT EXISTS mv_product_rank_monthly ( + id BIGINT AUTO_INCREMENT, + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + month_year VARCHAR(7) NOT NULL COMMENT '월간 키 (YYYY-MM)', + month_start_date DATE NOT NULL COMMENT '월간 시작일', + month_end_date DATE NOT NULL COMMENT '월간 종료일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (id), + UNIQUE KEY uk_monthly_ranking_month_position (month_start_date, rank_position), + INDEX idx_month_product (month_year, product_id), + INDEX idx_product_month (product_id, month_year) +) COMMENT '월간 상품 랭킹 (TOP 100)'; + +-- Product Metrics 테이블 (배치 소스) +CREATE TABLE IF NOT EXISTS product_metrics ( + product_id BIGINT NOT NULL, + like_count BIGINT NOT NULL DEFAULT 0, + view_count BIGINT NOT NULL DEFAULT 0, + sales_count BIGINT NOT NULL DEFAULT 0, + sales_amount BIGINT NOT NULL DEFAULT 0, + last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (product_id) +) COMMENT '상품 메트릭 집계 테이블'; diff --git a/docker/mysql/init/03-ranking-materialized-views.sql b/docker/mysql/init/03-ranking-materialized-views.sql new file mode 100644 index 000000000..2bf95412a --- /dev/null +++ b/docker/mysql/init/03-ranking-materialized-views.sql @@ -0,0 +1,28 @@ +-- 주간 랭킹 Materialized View +CREATE TABLE mv_product_rank_weekly ( + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + week_start_date DATE NOT NULL COMMENT '주간 시작일 (월요일)', + week_end_date DATE NOT NULL COMMENT '주간 종료일 (일요일)', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (week_start_date, rank_position), + INDEX idx_week_product (week_start_date, product_id), + INDEX idx_product_week (product_id, week_start_date) +) COMMENT '주간 상품 랭킹 (TOP 100)'; + +-- 월간 랭킹 Materialized View +CREATE TABLE mv_product_rank_monthly ( + rank_position INT NOT NULL, + product_id BIGINT NOT NULL, + total_score DOUBLE NOT NULL, + month_year VARCHAR(7) NOT NULL COMMENT '월간 키 (YYYY-MM)', + month_start_date DATE NOT NULL COMMENT '월간 시작일', + month_end_date DATE NOT NULL COMMENT '월간 종료일', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (month_year, rank_position), + INDEX idx_month_product (month_year, product_id), + INDEX idx_product_month (product_id, month_year) +) COMMENT '월간 상품 랭킹 (TOP 100)'; \ No newline at end of file diff --git a/modules/batch/build.gradle.kts b/modules/batch/build.gradle.kts new file mode 100644 index 000000000..015b925da --- /dev/null +++ b/modules/batch/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` +} + +dependencies { + // Spring Batch + api("org.springframework.boot:spring-boot-starter-batch") + + // JPA module + api(project(":modules:jpa")) + + // Test + testImplementation("org.springframework.batch:spring-batch-test") +} \ No newline at end of file diff --git a/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java b/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java new file mode 100644 index 000000000..aa6b62d9c --- /dev/null +++ b/modules/batch/src/main/java/com/loopers/config/batch/BatchConfig.java @@ -0,0 +1,16 @@ +package com.loopers.config.batch; + +import org.springframework.context.annotation.Configuration; + +/** + * Spring Batch Configuration + * + * Spring Boot 3.x에서는 @EnableBatchProcessing을 사용하지 않고 + * Auto-configuration을 사용합니다. + * + * spring.batch.job.enabled=false로 설정하여 자동 실행을 방지합니다. + */ +@Configuration +public class BatchConfig { + +} \ No newline at end of file diff --git a/modules/batch/src/main/resources/batch.yml b/modules/batch/src/main/resources/batch.yml new file mode 100644 index 000000000..a9970040d --- /dev/null +++ b/modules/batch/src/main/resources/batch.yml @@ -0,0 +1,6 @@ +spring: + batch: + job: + enabled: false # Job을 자동 실행하지 않음 (수동 실행) + jdbc: + initialize-schema: always # Batch 메타 테이블 초기화 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 906b49231..e17a8b181 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( ":modules:jpa", ":modules:redis", ":modules:kafka", + ":modules:batch", ":supports:jackson", ":supports:logging", ":supports:monitoring",