Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
10 commits
Select commit Hold shift + click to select a range
ab7d77b
feat: Spring Batch ๋ชจ๋“ˆ ์„ค์ • ๋ฐ Auto-configuration ์ ์šฉ
sylee6529 Jan 2, 2026
79a0f2b
feat: ๋žญํ‚น ๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฐ Period ์œ ํ‹ธ๋ฆฌํ‹ฐ ์ถ”๊ฐ€
sylee6529 Jan 2, 2026
bf82a12
feat: ๋žญํ‚น Repository ๊ตฌํ˜„ ๋ฐ Materialized View DDL ์ถ”๊ฐ€
sylee6529 Jan 2, 2026
3c35f07
feat: Batch Job์šฉ DTO, Processor, Writer ๊ตฌํ˜„
sylee6529 Jan 2, 2026
1c2d115
feat: ์ฃผ๊ฐ„/์›”๊ฐ„ ๋žญํ‚น Batch Job ์„ค์ • ๋ฐ Chunk ์ฒ˜๋ฆฌ ๊ตฌํ˜„
sylee6529 Jan 2, 2026
42f0dae
feat: ๋žญํ‚น Service ๊ธฐ๊ฐ„๋ณ„ ์กฐํšŒ ์ง€์› ์ถ”๊ฐ€
sylee6529 Jan 2, 2026
ea663e9
feat: Batch Job ์‹คํ–‰ API ๋ฐ Ranking API period ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€
sylee6529 Jan 2, 2026
0450d2a
test: ์ฃผ๊ฐ„/์›”๊ฐ„ ๋žญํ‚น Batch Job ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
sylee6529 Jan 2, 2026
b6dbc3d
fix: Batch DTO์˜ boxed Long์„ primitive long์œผ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ NPE ๋ฐฉ์ง€
sylee6529 Jan 2, 2026
b10a63d
feat: ๋žญํ‚น ํ…Œ์ด๋ธ”์— ์œ ๋‹ˆํฌ ์ œ์•ฝ ์กฐ๊ฑด ์ถ”๊ฐ€ํ•˜์—ฌ ๋™์‹œ ์‹คํ–‰ ์‹œ ๋ฐ์ดํ„ฐ ์†์‹ค ๋ฐฉ์ง€
sylee6529 Jan 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
.<ProductMetricsDto, RankedProductDto>chunk(CHUNK_SIZE, transactionManager)
.reader(monthlyRankingReader())
.processor(new RankingScoreProcessor(viewWeight, likeWeight, orderWeight))
.writer(collector)
.listener(monthlyRankingStepListener(collector))
.build();
}

/**
* Reader: ProductMetrics ํ…Œ์ด๋ธ”์„ ํŽ˜์ด์ง•์œผ๋กœ ์ฝ๊ธฐ
*/
private JdbcPagingItemReader<ProductMetricsDto> monthlyRankingReader() {
JdbcPagingItemReader<ProductMetricsDto> 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<ProductMetricsDto> 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<RankedProductDto> 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<RankedProductDto> topRankings = collectedItems.stream()
.limit(TOP_N)
.toList();

log.info("[MonthlyRanking] TOP {} ์„ ํƒ ์™„๋ฃŒ", topRankings.size());

// 5. ์ˆœ์œ„ ์„ค์ • ๋ฐ ์ €์žฅ
AtomicInteger rank = new AtomicInteger(1);
List<MonthlyRanking> 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;
}
};
}
}
Loading