Skip to content

Commit 5bd085e

Browse files
committed
feat: 월간 랭킹 배치 프로세스 및 Tasklet 구현
- 월간 랭킹 처리를 위한 도메인 엔티티(MonthlyRankingMV) 및 레포지토리 추가 - MonthlyRankingJobConfig와 Tasklet으로 배치 잡 구성 - 월간 점수 계산 및 랭킹 데이터 준비를 위한 Processing 로직 구현 - Redis를 활용한 스냅샷 데이터 처리 및 랭킹 데이터 스왑 로직 추가
1 parent 384ec82 commit 5bd085e

4 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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.monthly.MonthlyRankingTableSwapTasklet;
5+
import com.loopers.domain.ProductMetrics;
6+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.batch.core.Job;
9+
import org.springframework.batch.core.Step;
10+
import org.springframework.batch.core.job.builder.JobBuilder;
11+
import org.springframework.batch.core.launch.support.RunIdIncrementer;
12+
import org.springframework.batch.core.repository.JobRepository;
13+
import org.springframework.batch.core.step.builder.StepBuilder;
14+
import org.springframework.batch.item.ItemProcessor;
15+
import org.springframework.batch.item.database.JpaItemWriter;
16+
import org.springframework.batch.item.database.JpaPagingItemReader;
17+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
18+
import org.springframework.context.annotation.Bean;
19+
import org.springframework.context.annotation.Configuration;
20+
import org.springframework.transaction.PlatformTransactionManager;
21+
22+
@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME)
23+
@Configuration
24+
@RequiredArgsConstructor
25+
public class MonthlyRankingJobConfig {
26+
public static final String JOB_NAME = "monthlyRankingJob";
27+
28+
private final JobRepository jobRepository;
29+
private final PlatformTransactionManager transactionManager;
30+
private final RankingPrepareTasklet prepareTasklet;
31+
private final MonthlyRankingTableSwapTasklet tableSwapTasklet; // 월간 전용 스왑
32+
33+
private final JpaPagingItemReader<ProductMetrics> monthlyRankingReader;
34+
private final ItemProcessor<ProductMetrics, WeeklyRankingWork> rankingProcessor;
35+
private final JpaItemWriter<WeeklyRankingWork> rankingWriter;
36+
37+
@Bean(JOB_NAME)
38+
public Job monthlyRankingJob() {
39+
return new JobBuilder(JOB_NAME, jobRepository)
40+
.incrementer(new RunIdIncrementer())
41+
.start(monthlyPrepareStep())
42+
.next(monthlyCalculationStep())
43+
.next(monthlyTableSwapStep())
44+
.build();
45+
}
46+
47+
@Bean
48+
public Step monthlyPrepareStep() {
49+
return new StepBuilder("monthlyPrepareStep", jobRepository)
50+
.tasklet(prepareTasklet, transactionManager)
51+
.build();
52+
}
53+
54+
@Bean
55+
public Step monthlyCalculationStep() {
56+
return new StepBuilder("monthlyCalculationStep", jobRepository)
57+
.<ProductMetrics, WeeklyRankingWork>chunk(100, transactionManager)
58+
.reader(monthlyRankingReader) // 기간을 30일로 설정한 Reader
59+
.processor(rankingProcessor)
60+
.writer(rankingWriter)
61+
.build();
62+
}
63+
64+
@Bean
65+
public Step monthlyTableSwapStep() {
66+
return new StepBuilder("monthlyTableSwapStep", jobRepository)
67+
.tasklet(tableSwapTasklet, transactionManager)
68+
.build();
69+
}
70+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.loopers.batch.job.ranking.step.monthly;
2+
3+
import com.loopers.domain.rank.monthly.MonthlyRankingMV;
4+
import com.loopers.domain.rank.monthly.MonthlyRankingMVRepository;
5+
import com.loopers.domain.rank.monthly.ProductSnapshot;
6+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
7+
import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository;
8+
import java.util.List;
9+
import java.util.stream.IntStream;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.batch.core.StepContribution;
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.data.redis.core.RedisTemplate;
16+
import org.springframework.stereotype.Component;
17+
18+
@Component
19+
@RequiredArgsConstructor
20+
public class MonthlyRankingTableSwapTasklet implements Tasklet {
21+
22+
private final MonthlyRankingMVRepository monthlyMvRepository;
23+
private final WeeklyRankingWorkRepository workRepository;
24+
private final RedisTemplate<String, Object> redisTemplate; // Redis 사용
25+
26+
@Override
27+
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
28+
monthlyMvRepository.deleteAllInBatch();
29+
List<WeeklyRankingWork> workData = workRepository.findAll();
30+
if (workData.isEmpty()) return RepeatStatus.FINISHED;
31+
32+
List<String> keys = workData.stream()
33+
.map(work -> "product:snapshot:" + work.getProductId())
34+
.toList();
35+
36+
List<Object> snapshots = redisTemplate.opsForValue().multiGet(keys);
37+
38+
String baseDate = (String) chunkContext.getStepContext().getJobParameters().get("requestDate");
39+
if (baseDate == null) baseDate = "2026-01";
40+
41+
String finalBaseDate = baseDate;
42+
List<MonthlyRankingMV> newData = IntStream.range(0, workData.size())
43+
.mapToObj(i -> {
44+
WeeklyRankingWork work = workData.get(i);
45+
ProductSnapshot snapshot = (ProductSnapshot) snapshots.get(i); // Redis에서 가져온 스냅샷
46+
47+
if (snapshot == null) {
48+
return MonthlyRankingMV.createFromWork(work, finalBaseDate, "Unknown", 0L, true);
49+
}
50+
51+
return MonthlyRankingMV.createFromWork(
52+
work,
53+
"2026-01",
54+
snapshot.getName(),
55+
snapshot.getPrice(),
56+
snapshot.isSoldOut()
57+
);
58+
}).toList();
59+
60+
monthlyMvRepository.saveAll(newData);
61+
return RepeatStatus.FINISHED;
62+
}
63+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.loopers.domain.rank.monthly;
2+
3+
import com.loopers.domain.rank.weekly.WeeklyRankingWork;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Id;
8+
import jakarta.persistence.Table;
9+
import lombok.AccessLevel;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
@Entity
14+
@Getter
15+
@Table(name = "mv_product_rank_monthly")
16+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
17+
public class MonthlyRankingMV {
18+
19+
@Id
20+
@GeneratedValue(strategy = GenerationType.IDENTITY)
21+
private Long id;
22+
private String baseDate;
23+
private Long productId;
24+
private Double totalScore;
25+
private Integer currentRank;
26+
27+
private String productName;
28+
private Long price;
29+
private boolean isSoldOut;
30+
31+
public static MonthlyRankingMV createFromWork(WeeklyRankingWork work, String baseDate, String productName, Long price, boolean isSoldOut) {
32+
MonthlyRankingMV mv = new MonthlyRankingMV();
33+
mv.baseDate = baseDate;
34+
mv.productId = work.getProductId();
35+
mv.totalScore = work.getScore();
36+
mv.currentRank = work.getRanking();
37+
38+
mv.productName = productName;
39+
mv.price = price;
40+
mv.isSoldOut = isSoldOut;
41+
42+
return mv;
43+
}
44+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.loopers.domain.rank.monthly;
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 MonthlyRankingMVRepository extends JpaRepository<MonthlyRankingMV, Long> {
10+
11+
List<MonthlyRankingMV> findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable);
12+
}

0 commit comments

Comments
 (0)