Skip to content

Commit 384ec82

Browse files
committed
feat: 주간 랭킹 배치 프로세스 및 스케줄러 구현
- 주간 랭킹 처리를 위한 도메인 엔티티(WeeklyRankingMV, WeeklyRankingWork) 추가 - WeeklyRankingJobConfig 및 RankingChunkConfig로 배치 잡 구성 - WeeklyRankingProcessor 및 Tasklet을 통해 점수 계산, 데이터 준비 및 스왑 처리 구현 - 스케줄링을 통해 배치 잡 자동 실행 (매주 월요일 2시 설정) - MonthlyRankingJob 스켈레톤 추가, 향후 확장 고려
1 parent 6f4a795 commit 384ec82

12 files changed

Lines changed: 418 additions & 0 deletions

File tree

apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
77

88
import java.util.TimeZone;
9+
import org.springframework.scheduling.annotation.EnableScheduling;
910

11+
@EnableScheduling
1012
@ConfigurationPropertiesScan
1113
@SpringBootApplication
1214
public class CommerceBatchApplication {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.loopers.batch.job.ranking;
2+
3+
import com.loopers.batch.job.ranking.step.weekly.WeeklyRankingProcessor;
4+
import com.loopers.domain.ProductMetrics;
5+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
6+
import jakarta.persistence.EntityManagerFactory;
7+
import java.time.LocalDateTime;
8+
import java.util.Map;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.batch.core.configuration.annotation.StepScope;
11+
import org.springframework.batch.item.ItemProcessor;
12+
import org.springframework.batch.item.database.JpaItemWriter;
13+
import org.springframework.batch.item.database.JpaPagingItemReader;
14+
import org.springframework.batch.item.database.builder.JpaItemWriterBuilder;
15+
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
16+
import org.springframework.beans.factory.annotation.Value;
17+
import org.springframework.context.annotation.Bean;
18+
import org.springframework.context.annotation.Configuration;
19+
20+
@Configuration
21+
@RequiredArgsConstructor
22+
public class RankingChunkConfig {
23+
24+
private final WeeklyRankingProcessor weeklyRankingProcessor;
25+
private final EntityManagerFactory emf;
26+
27+
@Bean
28+
@StepScope
29+
public JpaPagingItemReader<ProductMetrics> rankingReader() {
30+
return new JpaPagingItemReaderBuilder<ProductMetrics>()
31+
.name("rankingReader")
32+
.entityManagerFactory(emf)
33+
.queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate")
34+
.parameterValues(Map.of("startDate", LocalDateTime.now().minusDays(7)))
35+
.pageSize(100)
36+
.build();
37+
}
38+
39+
@Bean
40+
public ItemProcessor<ProductMetrics, WeeklyRankingWork> rankingProcessor() {
41+
return weeklyRankingProcessor;
42+
}
43+
44+
@Bean
45+
@StepScope
46+
public JpaItemWriter<WeeklyRankingWork> rankingWriter() {
47+
return new JpaItemWriterBuilder<WeeklyRankingWork>()
48+
.entityManagerFactory(emf)
49+
.build();
50+
}
51+
52+
@Bean
53+
@StepScope
54+
public JpaPagingItemReader<ProductMetrics> monthlyRankingReader(
55+
@Value("#{jobParameters['startDate']}") String startDate
56+
) {
57+
return new JpaPagingItemReaderBuilder<ProductMetrics>()
58+
.name("monthlyRankingReader")
59+
.entityManagerFactory(emf)
60+
.queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate")
61+
.parameterValues(Map.of("startDate", LocalDateTime.parse(startDate)))
62+
.pageSize(100)
63+
.build();
64+
}
65+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.loopers.batch.job.ranking;
2+
3+
import com.loopers.batch.job.ranking.step.RankingPrepareTasklet;
4+
import com.loopers.batch.job.ranking.step.RankingTableSwapTasklet;
5+
import com.loopers.batch.listener.JobListener;
6+
import com.loopers.domain.ProductMetrics;
7+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.batch.core.Job;
10+
import org.springframework.batch.core.Step;
11+
import org.springframework.batch.core.job.builder.JobBuilder;
12+
import org.springframework.batch.core.launch.support.RunIdIncrementer;
13+
import org.springframework.batch.core.repository.JobRepository;
14+
import org.springframework.batch.core.step.builder.StepBuilder;
15+
import org.springframework.batch.item.ItemProcessor;
16+
import org.springframework.batch.item.ItemReader;
17+
import org.springframework.batch.item.ItemWriter;
18+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
19+
import org.springframework.context.annotation.Bean;
20+
import org.springframework.context.annotation.Configuration;
21+
import org.springframework.transaction.PlatformTransactionManager;
22+
23+
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME)
24+
@RequiredArgsConstructor
25+
@Configuration
26+
public class WeeklyRankingJobConfig {
27+
28+
public static final String JOB_NAME = "weeklyRankingJob";
29+
30+
private final JobRepository jobRepository;
31+
private final JobListener jobListener;
32+
private final PlatformTransactionManager transactionManager;
33+
34+
private final RankingPrepareTasklet prepareTasklet;
35+
private final RankingTableSwapTasklet tableSwapTasklet;
36+
37+
private final ItemReader<ProductMetrics> rankingReader;
38+
private final ItemProcessor<ProductMetrics, WeeklyRankingWork> rankingProcessor;
39+
private final ItemWriter<WeeklyRankingWork> rankingWriter;
40+
41+
@Bean(JOB_NAME)
42+
public Job weeklyRankingJob() {
43+
return new JobBuilder(JOB_NAME, jobRepository)
44+
.incrementer(new RunIdIncrementer())
45+
.start(prepareStep())
46+
.next(calculationStep())
47+
.next(tableSwapStep())
48+
.listener(jobListener)
49+
.build();
50+
}
51+
52+
@Bean
53+
public Step prepareStep() {
54+
return new StepBuilder("prepareStep", jobRepository)
55+
.tasklet(prepareTasklet, transactionManager)
56+
.build();
57+
}
58+
59+
@Bean
60+
public Step calculationStep() {
61+
return new StepBuilder("calculationStep", jobRepository)
62+
.<ProductMetrics, WeeklyRankingWork>chunk(100, transactionManager)
63+
.reader(rankingReader)
64+
.processor(rankingProcessor)
65+
.writer(rankingWriter)
66+
.build();
67+
}
68+
69+
@Bean
70+
public Step tableSwapStep() {
71+
return new StepBuilder("tableSwapStep", jobRepository)
72+
.tasklet(tableSwapTasklet, transactionManager)
73+
.build();
74+
}
75+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.loopers.batch.job.ranking.scheduler;
2+
3+
import java.time.LocalDateTime;
4+
import java.time.format.DateTimeFormatter;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.batch.core.Job;
8+
import org.springframework.batch.core.JobParametersBuilder;
9+
import org.springframework.batch.core.launch.JobLauncher;
10+
import org.springframework.beans.factory.annotation.Qualifier;
11+
import org.springframework.scheduling.annotation.Scheduled;
12+
import org.springframework.stereotype.Component;
13+
14+
@Slf4j
15+
@Component
16+
@RequiredArgsConstructor
17+
public class RankingScheduler {
18+
19+
private final JobLauncher jobLauncher;
20+
21+
@Qualifier("weeklyRankingJob")
22+
private final Job weeklyRankingJob;
23+
24+
@Qualifier("monthlyRankingJob")
25+
private final Job monthlyRankingJob;
26+
27+
@Scheduled(cron = "0 0 2 * * MON")
28+
public void runWeeklyRankingJob() {
29+
String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
30+
log.info(">>> Weekly Ranking Job Scheduler Start: {}", requestDate);
31+
32+
try {
33+
jobLauncher.run(weeklyRankingJob, new JobParametersBuilder()
34+
.addString("requestDate", requestDate)
35+
.addLong("timestamp", System.currentTimeMillis())
36+
.toJobParameters());
37+
} catch (Exception e) {
38+
log.error(">>> Weekly Ranking Job Error: {}", e.getMessage());
39+
}
40+
}
41+
42+
@Scheduled(cron = "0 0 3 1 * *")
43+
public void runMonthlyRankingJob() {
44+
String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
45+
String startDate = LocalDateTime.now().minusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
46+
47+
log.info(">>> Monthly Ranking Job Scheduler Start: {}", requestDate);
48+
49+
try {
50+
jobLauncher.run(monthlyRankingJob, new JobParametersBuilder()
51+
.addString("requestDate", requestDate)
52+
.addString("startDate", startDate)
53+
.addLong("timestamp", System.currentTimeMillis())
54+
.toJobParameters());
55+
} catch (Exception e) {
56+
log.error(">>> Monthly Ranking Job Error: {}", e.getMessage());
57+
}
58+
}
59+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.loopers.batch.job.ranking.step;
2+
3+
import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.batch.core.StepContribution;
6+
import org.springframework.batch.core.configuration.annotation.StepScope;
7+
import org.springframework.batch.core.scope.context.ChunkContext;
8+
import org.springframework.batch.core.step.tasklet.Tasklet;
9+
import org.springframework.batch.repeat.RepeatStatus;
10+
import org.springframework.stereotype.Component;
11+
12+
@Component
13+
@StepScope
14+
@RequiredArgsConstructor
15+
public class RankingPrepareTasklet implements Tasklet {
16+
17+
private final WeeklyRankingWorkRepository workingRepository;
18+
19+
@Override
20+
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
21+
workingRepository.deleteAllInBatch();
22+
return RepeatStatus.FINISHED;
23+
}
24+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.loopers.batch.job.ranking.step;
2+
3+
import com.loopers.domain.rank.weekly.WeeklyRankingMV;
4+
import com.loopers.domain.rank.weekly.WeeklyRankingMVRepository;
5+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
6+
import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository;
7+
import java.time.LocalDate;
8+
import java.util.List;
9+
import lombok.AllArgsConstructor;
10+
import org.springframework.batch.core.StepContribution;
11+
import org.springframework.batch.core.configuration.annotation.StepScope;
12+
import org.springframework.batch.core.scope.context.ChunkContext;
13+
import org.springframework.batch.core.step.tasklet.Tasklet;
14+
import org.springframework.batch.repeat.RepeatStatus;
15+
import org.springframework.stereotype.Component;
16+
17+
@Component
18+
@StepScope
19+
@AllArgsConstructor
20+
public class RankingTableSwapTasklet implements Tasklet {
21+
22+
private final WeeklyRankingMVRepository mvRepository;
23+
private final WeeklyRankingWorkRepository workRepository;
24+
25+
@Override
26+
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
27+
String baseDate = LocalDate.now().toString();
28+
29+
mvRepository.deleteAllInBatch();
30+
31+
List<WeeklyRankingWork> workData = workRepository.findAll();
32+
33+
List<WeeklyRankingMV> newData = workData.stream()
34+
.map(work -> WeeklyRankingMV.createFromWork(work, baseDate))
35+
.toList();
36+
37+
mvRepository.saveAll(newData);
38+
return RepeatStatus.FINISHED;
39+
}
40+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.loopers.batch.job.ranking.step.weekly;
2+
3+
import com.loopers.domain.ProductMetrics;
4+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
5+
import org.springframework.batch.core.configuration.annotation.StepScope;
6+
import org.springframework.batch.item.ItemProcessor;
7+
import org.springframework.stereotype.Component;
8+
9+
@Component
10+
@StepScope
11+
public class WeeklyRankingProcessor implements ItemProcessor<ProductMetrics, WeeklyRankingWork> {
12+
13+
private int rankCounter = 0;
14+
15+
@Override
16+
public WeeklyRankingWork process(ProductMetrics item) {
17+
rankCounter++;
18+
if (rankCounter > 100) {
19+
return null;
20+
}
21+
22+
Double score = (item.getViewCount() * 0.1) + (item.getLikeCount() * 0.2) + (item.getSalesCount() * 0.6);
23+
24+
return new WeeklyRankingWork(
25+
item.getProductId(),
26+
score,
27+
rankCounter
28+
);
29+
}
30+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.loopers.domain.rank.monthly;
2+
3+
import java.io.Serializable;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
public class ProductSnapshot implements Serializable {
12+
private String name;
13+
private long price;
14+
private boolean isSoldOut;
15+
16+
public static ProductSnapshot of(String name, long price, boolean isSoldOut) {
17+
return new ProductSnapshot(name, price, isSoldOut);
18+
}
19+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.loopers.domain.rank.weekly;
2+
3+
import jakarta.persistence.Entity;
4+
import jakarta.persistence.GeneratedValue;
5+
import jakarta.persistence.GenerationType;
6+
import jakarta.persistence.Id;
7+
import jakarta.persistence.Table;
8+
import lombok.AccessLevel;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
12+
@Entity
13+
@Table(name = "mv_product_rank_weekly")
14+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
15+
@Getter
16+
public class WeeklyRankingMV {
17+
@Id
18+
@GeneratedValue(strategy = GenerationType.IDENTITY)
19+
private Long id;
20+
21+
private String baseDate;
22+
private Long productId;
23+
private Double totalScore;
24+
private Integer currentRank;
25+
26+
private String productName;
27+
private Long price;
28+
private boolean isSoldOut;
29+
30+
private WeeklyRankingMV(String baseDate, Long productId, Double totalScore, Integer currentRank,
31+
String productName, Long price, boolean isSoldOut) {
32+
this.baseDate = baseDate;
33+
this.productId = productId;
34+
this.totalScore = totalScore;
35+
this.currentRank = currentRank;
36+
this.productName = productName;
37+
this.price = price;
38+
this.isSoldOut = isSoldOut;
39+
}
40+
41+
// 정적 팩토리 메서드 (의미 있는 생성 방식 제공)
42+
public static WeeklyRankingMV createFromWork(WeeklyRankingWork work, String baseDate) {
43+
return new WeeklyRankingMV(
44+
baseDate,
45+
work.getProductId(),
46+
work.getScore(),
47+
work.getRanking(),
48+
"상품명 임시", // 실제 구현 시 Product 정보 결합 필요
49+
0L, // 실제 구현 시 Product 정보 결합 필요
50+
false // 실제 구현 시 Product 정보 결합 필요
51+
);
52+
}
53+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.loopers.domain.rank.weekly;
2+
3+
import java.util.List;
4+
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
@Repository
9+
public interface WeeklyRankingMVRepository extends JpaRepository<WeeklyRankingMV, Long> {
10+
11+
List<WeeklyRankingMV> findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable);
12+
}

0 commit comments

Comments
 (0)