-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-10] Spring Batch 기반 주간/월간 랭킹 시스템 구현 #410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: pable91
Are you sure you want to change the base?
Changes from all commits
a1415fb
698ce7e
d15406d
c0860a5
1110a22
1c31308
3e1b790
fe12b12
50172d7
3433e33
d073d2b
192e236
a7f9a3b
c9e6053
fa3c0bb
35d4c17
42d201a
2f311e5
fc9047b
ebaca99
da56e8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.PrePersist; | ||
| import jakarta.persistence.PreUpdate; | ||
| import jakarta.persistence.Table; | ||
| import java.time.LocalDateTime; | ||
| import lombok.NoArgsConstructor; | ||
| import org.hibernate.annotations.Comment; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_monthly") | ||
| @NoArgsConstructor | ||
| public class MvProductRankMonthlyEntity { | ||
|
|
||
| @Id | ||
| @Comment("상품 id (ref)") | ||
| @Column(name = "product_id", nullable = false, updatable = false) | ||
| private Long productId; | ||
|
|
||
| @Comment("집계 점수") | ||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Comment("집계 기준 월 (e.g. 202604)") | ||
| @Column(name = "year_month", nullable = false, length = 6) | ||
| private String yearMonth; | ||
|
|
||
| @Comment("랭킹 순위") | ||
| @Column(name = "product_rank", nullable = false) | ||
| private int productRank; | ||
|
|
||
| @Column(name = "created_at", nullable = false, updatable = false) | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public MvProductRankMonthlyEntity(Long productId, double score, String yearMonth, int productRank) { | ||
| this.productId = productId; | ||
| this.score = score; | ||
| this.yearMonth = yearMonth; | ||
| this.productRank = productRank; | ||
| } | ||
|
Comment on lines
+16
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 단일
추가 테스트: (1) 서로 다른 🤖 Prompt for AI Agents |
||
|
|
||
| public void update(double score, String yearMonth, int productRank) { | ||
| this.score = score; | ||
| this.yearMonth = yearMonth; | ||
| this.productRank = productRank; | ||
| } | ||
|
|
||
| @PrePersist | ||
| private void prePersist() { | ||
| LocalDateTime now = LocalDateTime.now(); | ||
| this.createdAt = now; | ||
| this.updatedAt = now; | ||
| } | ||
|
|
||
| @PreUpdate | ||
| private void preUpdate() { | ||
| this.updatedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public Long getProductId() { | ||
| return productId; | ||
| } | ||
|
|
||
| public double getScore() { | ||
| return score; | ||
| } | ||
|
|
||
| public String getYearMonth() { | ||
| return yearMonth; | ||
| } | ||
|
|
||
| public int getProductRank() { | ||
| return productRank; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import java.util.List; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthlyEntity, Long> { | ||
|
|
||
| List<MvProductRankMonthlyEntity> findAllByYearMonthOrderByProductRankAsc(String yearMonth, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.PrePersist; | ||
| import jakarta.persistence.PreUpdate; | ||
| import jakarta.persistence.Table; | ||
| import java.time.LocalDateTime; | ||
| import lombok.NoArgsConstructor; | ||
| import org.hibernate.annotations.Comment; | ||
|
|
||
| @Entity | ||
| @Table(name = "mv_product_rank_weekly") | ||
| @NoArgsConstructor | ||
| public class MvProductRankWeeklyEntity { | ||
|
|
||
| @Id | ||
| @Comment("상품 id (ref)") | ||
| @Column(name = "product_id", nullable = false, updatable = false) | ||
| private Long productId; | ||
|
|
||
| @Comment("집계 점수") | ||
| @Column(name = "score", nullable = false) | ||
| private double score; | ||
|
|
||
| @Comment("집계 기준 주 (해당 주 월요일, e.g. 20260414)") | ||
| @Column(name = "year_week", nullable = false, length = 8) | ||
| private String yearWeek; | ||
|
|
||
| @Comment("랭킹 순위") | ||
| @Column(name = "product_rank", nullable = false) | ||
| private int productRank; | ||
|
|
||
| @Column(name = "created_at", nullable = false, updatable = false) | ||
| private LocalDateTime createdAt; | ||
|
|
||
| @Column(name = "updated_at", nullable = false) | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| public MvProductRankWeeklyEntity(Long productId, double score, String yearWeek, int productRank) { | ||
| this.productId = productId; | ||
| this.score = score; | ||
| this.yearWeek = yearWeek; | ||
| this.productRank = productRank; | ||
| } | ||
|
|
||
| public void update(double score, String yearWeek, int productRank) { | ||
| this.score = score; | ||
| this.yearWeek = yearWeek; | ||
| this.productRank = productRank; | ||
| } | ||
|
|
||
| @PrePersist | ||
| private void prePersist() { | ||
| LocalDateTime now = LocalDateTime.now(); | ||
| this.createdAt = now; | ||
| this.updatedAt = now; | ||
| } | ||
|
|
||
| @PreUpdate | ||
| private void preUpdate() { | ||
| this.updatedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public Long getProductId() { | ||
| return productId; | ||
| } | ||
|
|
||
| public double getScore() { | ||
| return score; | ||
| } | ||
|
|
||
| public String getYearWeek() { | ||
| return yearWeek; | ||
| } | ||
|
|
||
| public int getProductRank() { | ||
| return productRank; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.infrastructure.ranking; | ||
|
|
||
| import java.util.List; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeeklyEntity, Long> { | ||
|
|
||
| List<MvProductRankWeeklyEntity> findAllByYearWeekOrderByProductRankAsc(String yearWeek, Pageable pageable); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,13 +3,15 @@ | |
| import com.loopers.config.redis.RedisConfig; | ||
| import com.loopers.domain.ranking.RankedProduct; | ||
| import com.loopers.domain.ranking.RankingRepository; | ||
| import java.time.DayOfWeek; | ||
| import java.time.LocalDate; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.Set; | ||
| import java.util.concurrent.TimeUnit; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.redis.connection.zset.Aggregate; | ||
| import org.springframework.data.redis.connection.zset.Weights; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
|
|
@@ -25,13 +27,19 @@ public class RankingRepositoryImpl implements RankingRepository { | |
|
|
||
| private final RedisTemplate<String, String> redisTemplate; | ||
| private final RedisTemplate<String, String> masterRedisTemplate; | ||
| private final MvProductRankWeeklyJpaRepository weeklyRepository; | ||
| private final MvProductRankMonthlyJpaRepository monthlyRepository; | ||
|
|
||
| public RankingRepositoryImpl( | ||
| RedisTemplate<String, String> redisTemplate, | ||
| @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate | ||
| @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate, | ||
| MvProductRankWeeklyJpaRepository weeklyRepository, | ||
| MvProductRankMonthlyJpaRepository monthlyRepository | ||
| ) { | ||
| this.redisTemplate = redisTemplate; | ||
| this.masterRedisTemplate = masterRedisTemplate; | ||
| this.weeklyRepository = weeklyRepository; | ||
| this.monthlyRepository = monthlyRepository; | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -48,6 +56,24 @@ public List<RankedProduct> getTopN(LocalDate date, int size, int page) { | |
| .toList(); | ||
| } | ||
|
|
||
| @Override | ||
| public List<RankedProduct> getWeeklyTopN(LocalDate date, int size, int page) { | ||
| String yearWeek = date.with(DayOfWeek.MONDAY).format(DateTimeFormatter.BASIC_ISO_DATE); | ||
| return weeklyRepository.findAllByYearWeekOrderByProductRankAsc(yearWeek, PageRequest.of(page, size)) | ||
| .stream() | ||
| .map(e -> new RankedProduct(e.getProductId(), e.getScore())) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Override | ||
| public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) { | ||
| String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM")); | ||
| return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size)) | ||
| .stream() | ||
| .map(e -> new RankedProduct(e.getProductId(), e.getScore())) | ||
| .toList(); | ||
| } | ||
|
Comment on lines
+68
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 월 단위 포맷 상수를 배치와 공유해 포맷 드리프트를 차단하도록 한다. 배치 writer( ♻️ 제안 수정- `@Override`
- public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
- String yearMonth = date.format(DateTimeFormatter.ofPattern("yyyyMM"));
+ private static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
+
+ `@Override`
+ public List<RankedProduct> getMonthlyTopN(LocalDate date, int size, int page) {
+ String yearMonth = date.format(YEAR_MONTH_FORMAT);
return monthlyRepository.findAllByYearMonthOrderByProductRankAsc(yearMonth, PageRequest.of(page, size))
.stream()
.map(e -> new RankedProduct(e.getProductId(), e.getScore()))
.toList();
}공용 포맷 상수(예: 추가 테스트 권고
🤖 Prompt for AI Agents |
||
|
|
||
| @Override | ||
| public Long getRank(Long productId, LocalDate date) { | ||
| String key = KEY_PREFIX + date.format(DATE_FORMATTER); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package com.loopers.batch.job.ranking; | ||
|
|
||
| import com.loopers.batch.listener.JobListener; | ||
| import com.loopers.batch.listener.StepMonitorListener; | ||
| import com.loopers.batch.job.ranking.step.MonthlyMvRankingItemWriter; | ||
| import com.loopers.batch.job.ranking.step.MonthlyRankAssignTasklet; | ||
| import com.loopers.batch.job.ranking.step.RankedProductDto; | ||
| import com.loopers.batch.job.ranking.step.RankingItemProcessor; | ||
| import com.loopers.infrastructure.metrics.ProductMetricsAggregatedDto; | ||
| import java.time.format.DateTimeFormatter; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.batch.core.Job; | ||
| import org.springframework.batch.core.Step; | ||
| import org.springframework.batch.core.job.builder.JobBuilder; | ||
| 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.boot.autoconfigure.condition.ConditionalOnProperty; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.transaction.PlatformTransactionManager; | ||
|
|
||
| @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = RankingMonthlyJobConfig.JOB_NAME) | ||
| @RequiredArgsConstructor | ||
| @Configuration | ||
| public class RankingMonthlyJobConfig { | ||
|
|
||
| public static final String JOB_NAME = "rankingMonthlyJob"; | ||
| public static final DateTimeFormatter YEAR_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM"); | ||
| private static final int CHUNK_SIZE = 100; | ||
|
|
||
| private final JobRepository jobRepository; | ||
| private final JobListener jobListener; | ||
| private final StepMonitorListener stepMonitorListener; | ||
| private final PlatformTransactionManager transactionManager; | ||
| private final JdbcPagingItemReader<ProductMetricsAggregatedDto> productMetricsItemReader; | ||
| private final RankingItemProcessor rankingItemProcessor; | ||
| private final MonthlyMvRankingItemWriter monthlyMvRankingItemWriter; | ||
| private final MonthlyRankAssignTasklet monthlyRankAssignTasklet; | ||
|
|
||
| @Bean(JOB_NAME) | ||
| public Job rankingMonthlyJob() { | ||
| return new JobBuilder(JOB_NAME, jobRepository) | ||
| .start(monthlyScoreCalculationStep()) | ||
| .next(monthlyRankAssignStep()) | ||
| .listener(jobListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean("monthlyScoreCalculationStep") | ||
| public Step monthlyScoreCalculationStep() { | ||
| return new StepBuilder("monthlyScoreCalculationStep", jobRepository) | ||
| .<ProductMetricsAggregatedDto, RankedProductDto>chunk(CHUNK_SIZE, transactionManager) | ||
| .reader(productMetricsItemReader) | ||
| .processor(rankingItemProcessor) | ||
| .writer(monthlyMvRankingItemWriter) | ||
| .listener(stepMonitorListener) | ||
| .build(); | ||
| } | ||
|
|
||
| @Bean("monthlyRankAssignStep") | ||
| public Step monthlyRankAssignStep() { | ||
| return new StepBuilder("monthlyRankAssignStep", jobRepository) | ||
| .tasklet(monthlyRankAssignTasklet, transactionManager) | ||
| .listener(stepMonitorListener) | ||
| .build(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Is YEAR_MONTH a reserved keyword in MySQL 8.0 and does it require quoting when used as a column identifier?💡 Result:
Yes, YEAR_MONTH is a reserved keyword in MySQL 8.0. It requires quoting (using backticks) when used as a column identifier.
Citations:
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 288
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 502
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4745
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 107
🏁 Script executed:
# Check the WeeklyEntity for similar issues cat ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankWeeklyEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2187
🏁 Script executed:
# Check the database schema/DDL cat ./db/seed-products.sqlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1357
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 132
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 111
🏁 Script executed:
# Check the application configuration for Hibernate settings cat ./apps/commerce-api/src/main/resources/application.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1670
🏁 Script executed:
# Check the batch ranking documentation cat ./docs/10-batch-ranking.mdRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 8489
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2635
🏁 Script executed:
# Check for any Hibernate configuration that might handle reserved keywords cat ./apps/commerce-api/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 163
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 119
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4522
🏁 Script executed:
# Check if the batch module has the same issue - compare both files diff -u ./apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.java ./apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MvProductRankMonthlyEntity.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1353
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 297
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 69
🏁 Script executed:
# Read the jpa.yml configuration file cat ./modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2231
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 126
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 132
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1228
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 252
🏁 Script executed:
# Check root build.gradle.kts for dependency versions cat ./build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3761
🏁 Script executed:
# Check modules/jpa build.gradle.kts cat ./modules/jpa/build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 763
year_month는 MySQL 8.0 예약어이므로 백틱으로 이스케이프해야 한다.YEAR_MONTH는 MySQL 8.0의 INTERVAL 단위 키워드로 예약되어 있어 DDL/DML 파싱 에러가 발생할 수 있다. 현재 코드의
@Column(name = "year_month", ...)형태로는 예약어 충돌 위험이 있으므로, Hibernate에서 명시적으로 인식하도록 백틱으로 감싸야 한다.commerce-api와 commerce-batch 두 모듈의 MvProductRankMonthlyEntity 파일 모두에서 동일하게 수정이 필요하다.
📝 수정안
테스트: Testcontainers MySQL 8.0 환경에서 persist/update/derived query(findAllByYearMonth 등) 모두 정상 실행되는지 검증한다.
🤖 Prompt for AI Agents