-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-10] Collect, Stack, Zip #230
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: jeonje
Are you sure you want to change the base?
Conversation
ProductMetrics 엔티티에 복합키(ref_product_id, metric_date)를 도입하여 날짜별로 조회수, 좋아요수, 판매수를 집계할 수 있는 구조로 변경 주요 변경: - ProductMetricsId 복합키 도입 - MetricDateConverter로 epoch 밀리초를 yyyyMMdd 정수로 변환 - Repository upsert 메서드에 metricDate 파라미터 추가 - 이벤트 전략에서 MetricDateConverter 사용 - 통합 테스트를 복합키 기반으로 수정
product_metrics 데이터를 집계하여 주간/월간 랭킹을 계산하는 Spring Batch Job을 구현 배치 Job: - RankingAggregationJobConfig로 Reader-Processor-Writer 구성 - RankingItemProcessor에서 가중치 기반 점수 계산 (view:0.1, like:0.3, order:0.6) - period 파라미터로 WEEKLY/MONTHLY 구분 도메인: - ProductMetrics 읽기 전용 엔티티 추가 (배치 집계 소스) - WeeklyProductRank/MonthlyProductRank 랭킹 결과 엔티티 추가 인터페이스: - POST /api/v1/jobs/ranking 수동 실행 API 추가 테스트: - RankingAggregationJobE2ETest E2E 테스트 추가
배치 Job이 생성한 랭킹 MV 테이블을 조회하는 API를 추가 API: - GET /api/v1/rankings/weekly 주간 랭킹 조회 - GET /api/v1/rankings/monthly 월간 랭킹 조회 도메인: - WeeklyProductRank/MonthlyProductRank MV 엔티티 추가 - RankingService에 주간/월간 조회 메서드 추가 애플리케이션: - RankingFacade에 주간/월간 조회 메서드 추가
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Walkthrough주간 및 월간 랭킹 조회 기능을 추가하고 새로운 배치 모듈(commerce-batch)을 도입하여 랭킹 데이터를 집계하는 시스템을 구축합니다. 메트릭 날짜 처리 및 MV 엔티티 매핑을 통해 시계열 랭킹 데이터를 관리합니다. Changes
Sequence DiagramssequenceDiagram
participant Client
participant RankingController
participant RankingFacade
participant RankingService
participant Repository as WeeklyProductRankRepository
participant DB as Database (MV)
Client->>RankingController: GET /api/v1/rankings/weekly<br/>(userId, date, page, size)
RankingController->>RankingFacade: getWeeklyRanking(date, page, size, userId)
RankingFacade->>RankingService: getWeeklyTopN(date, page, size)
RankingService->>RankingService: toYearWeek(date)<br/>(Korea locale)
RankingService->>Repository: findByYearWeekOrderByScoreDesc(yearWeek, page, size)
Repository->>DB: SELECT * FROM mv_product_rank_weekly<br/>WHERE ranking_year_week = ?<br/>ORDER BY score DESC
DB-->>Repository: [WeeklyProductRank, ...]
Repository-->>RankingService: List<WeeklyProductRank>
RankingService->>RankingService: toRankingEntries(results)
RankingService-->>RankingFacade: List<RankingEntry>
RankingFacade->>RankingFacade: buildResult(entries, date, page, size, userId)<br/>(resolve products, brands, likes)
RankingFacade-->>RankingController: RankingResult
RankingController-->>Client: ApiResponse<RankingResponse>
sequenceDiagram
participant Scheduler
participant BatchJobController
participant BatchJobFacade
participant JobLauncher
participant RankingAggregationJob
participant Reader as ItemReader
participant Processor as RankingItemProcessor
participant Writer as ItemWriter
participant MetricsDB as ProductMetrics DB
participant MVDB as MV Database
Scheduler->>BatchJobController: POST /api/v1/jobs/ranking<br/>(period=WEEKLY, date=2025-01-15)
BatchJobController->>BatchJobFacade: runRankingAggregation(WEEKLY, date)
BatchJobFacade->>BatchJobFacade: format date (yyyyMMdd)<br/>create JobParameters
BatchJobFacade->>JobLauncher: run(rankingAggregationJob, params)
JobLauncher->>RankingAggregationJob: execute
rect rgb(200, 220, 255)
note over Reader,MVDB: 청크 처리 루프
Reader->>Reader: buildQuery(period, baseDate)<br/>compute DateRange
Reader->>MetricsDB: SELECT SUM(view_count), SUM(like_count),<br/>SUM(sales_count) FROM product_metrics<br/>WHERE metric_date IN [dateRange]<br/>GROUP BY ref_product_id<br/>ORDER BY weighted_score DESC
MetricsDB-->>Reader: [AggregatedProductScore, ...]
Reader-->>Processor: item batch
Processor->>Processor: compute yearWeek/yearMonth<br/>calculate score(weights)
Processor-->>Writer: [ProductRankEntity, ...]
Writer->>MVDB: INSERT INTO mv_product_rank_weekly<br/>(ref_product_id, ranking_year_week, score, ...)<br/>ON DUPLICATE KEY UPDATE
MVDB-->>Writer: success
end
RankingAggregationJob-->>JobLauncher: COMPLETED
JobLauncher-->>BatchJobFacade: JobExecution
BatchJobFacade-->>BatchJobController: JobExecutionResult
BatchJobController-->>Scheduler: 200 OK (jobId, status, message)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@CodeRabbit 아래체크리스트 만족했는지 확인해주세요. ✅ Checklist🧱 Spring Batch
🧩 Ranking API
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 131928 🏁 Script executed: Length of output: 18034 @JeonJe 체크리스트 항목들을 확인했습니다! 모든 항목이 만족되었습니다. 👍 ✅ Spring Batch✅ Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다
✅ Chunk Oriented Processing 기반의 배치 처리를 구현했다Reader:
Processor:
Writer:
✅ Materialized View의 구조를 설계하고 올바르게 적재했다주간 MV:
월간 MV:
적재 흐름: ProductMetrics → 집계(Reader) → 점수계산(Processor) → MV 저장(Writer) ✅ Ranking API✅ API가 일간, 주간, 월간 랭킹을 제공하며 적절한 데이터를 기반으로 제공한다엔드포인트:
각 랭킹 형태에 따라 최적의 데이터 소스를 사용하고 있습니다! 🎯 |
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.
Actionable comments posted: 8
♻️ Duplicate comments (1)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java (1)
1-48: 중복 클래스 - 위 파일 리뷰 참조
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java와 완전히 동일한 구현입니다. 공통 모듈로 통합하여 DRY 원칙을 준수하시기 바랍니다.
🧹 Nitpick comments (26)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
10-10: 불필요한@RequiredArgsConstructor애노테이션현재 클래스에
final필드가 없어@RequiredArgsConstructor애노테이션이 실질적으로 아무 역할도 하지 않습니다. 필요하지 않다면 제거하는 것이 좋습니다.🔎 제안된 수정
@Slf4j -@RequiredArgsConstructor @Component public class ChunkListener {apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java (1)
4-8: 날짜 값에 대한 유효성 검증을 추가하는 것을 권장합니다.현재 구현은 다음과 같은 잠재적 문제가 있습니다:
startDate가endDate보다 큰 값이 허용됨 (논리적 오류)- 유효하지 않은 날짜 값(예: 20260132)이 허용됨
대안으로 다음을 고려해보세요:
- 캐노니컬 생성자에서 유효성 검증 추가
java.time.LocalDate를 사용하여 타입 안정성 확보🔎 유효성 검증을 추가한 제안
// 집계 기간 (시작일, 종료일 - YYYYMMDD 형식) -public record DateRange(int startDate, int endDate) { +public record DateRange(int startDate, int endDate) { + + public DateRange { + if (startDate > endDate) { + throw new IllegalArgumentException("startDate must be less than or equal to endDate"); + } + // Optional: Add date format validation + validateDateFormat(startDate); + validateDateFormat(endDate); + } + + private static void validateDateFormat(int date) { + String dateStr = String.valueOf(date); + if (dateStr.length() != 8) { + throw new IllegalArgumentException("Date must be in YYYYMMDD format: " + date); + } + } public static DateRange of(int startDate, int endDate) { return new DateRange(startDate, endDate); } }apps/commerce-batch/src/test/resources/sql/cleanup.sql (1)
1-3: 테스트 클린업에서 TRUNCATE 사용 검토를 권장합니다.현재 cleanup.sql의 DELETE 문은 기능적으로 문제가 없습니다. 코드베이스에서 이 세 테이블 간 외래 키 제약 조건을 찾을 수 없으므로 삭제 순서는 중요하지 않습니다.
다만 테스트 클린업 성능 개선을 위해 DELETE 대신
TRUNCATE명령 사용을 고려해볼 수 있습니다:
TRUNCATE는 DELETE보다 빠르고 디스크 공간을 즉시 반환합니다.- 테스트 전용 H2 데이터베이스에는 적합한 최적화입니다.
apps/commerce-batch/src/main/resources/application.yml (1)
46-62: 빈 프로파일 섹션 정리 고려.
dev,qa,prd프로파일 섹션이 현재 비어 있습니다. 향후 환경별 설정이 추가될 예정이라면 유지하고, 그렇지 않다면 제거하여 설정 파일을 간결하게 유지할 수 있습니다.apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java (1)
1-8: LGTM!도메인 레이어가 Spring Data의 인프라 타입(Pageable)에 의존하지 않도록 원시 타입(int page, int size)으로 페이지네이션을 처리하는 것은 좋은 아키텍처 설계입니다. 구현체(MonthlyProductRankRepositoryImpl)에서 PageRequest로 변환하는 패턴이 적절하게 적용되었습니다.
페이지네이션 파라미터의 경계 조건(음수 또는 과도하게 큰 값) 검증이 구현체 레벨에서 처리되고 있는지 확인하시면 좋을 것 같습니다.
#!/bin/bash # MonthlyProductRankRepositoryImpl의 페이지네이션 파라미터 검증 확인 rg -A 10 "class MonthlyProductRankRepositoryImpl" --type javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java (1)
27-29: LGTM!메트릭 날짜 계산 및 리포지토리 호출이 올바르게 구현되었습니다. 타임존을 고려한 날짜 변환과 로깅 개선이 적절합니다.
다른 메트릭 전략들(ProductViewedStrategy, ProductSoldStrategy 등)에서도 동일한 타임존 설정을 사용하는지 확인하여 일관성을 보장하면 좋겠습니다.
#!/bin/bash # 모든 전략 클래스에서 RankingRedisProperties 사용 확인 rg -n "class.*Strategy.*implements.*EventStrategy" apps/commerce-streamer --type java -A 20 | rg -A 5 "RankingRedisProperties"apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
27-29: 로거 사용을 권장합니다.데모 코드이지만 일관성을 위해
System.out.println대신 Lombok의@Slf4j로거를 사용하는 것이 좋습니다. 다른 배치 컴포넌트들(StepMonitorListener, ChunkListener 등)과 동일한 로깅 방식을 따르면 로그 관리와 모니터링이 용이합니다.🔎 로거 사용 제안
클래스에
@Slf4j어노테이션을 추가하세요:+@Slf4j @StepScope @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) @RequiredArgsConstructor @Component public class DemoTasklet implements Tasklet {그리고 System.out.println을 log 호출로 변경하세요:
- System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate); Thread.sleep(1000); - System.out.println("Demo Tasklet 작업 완료"); + log.info("Demo Tasklet 작업 완료");apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java (1)
74-80: 자정 경계에서의 잠재적 테스트 불안정성두 번째 이벤트가
now + 1밀리초에 발생하지만,metricDate는 첫 번째 이벤트의now값을 기준으로 계산됩니다. 자정 직전에 테스트가 실행되면 두 이벤트가 서로 다른 날짜에 기록될 수 있어 테스트가 실패할 수 있습니다. 다른 테스트 케이스(lines 143-154, 223-232)도 동일한 패턴을 사용합니다.실제로 발생 가능성이 낮지만, 고정된 시간을 사용하거나 두 이벤트 모두에 대해 동일한 타임스탬프를 사용하면 더 안정적입니다.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java (1)
6-9:serialVersionUID누락 확인 필요
Serializable을 구현하는 JPA 복합 키 클래스에는serialVersionUID를 명시적으로 선언하는 것이 권장됩니다. 이를 생략하면 클래스 구조 변경 시 역직렬화 호환성 문제가 발생할 수 있습니다.🔎 제안된 수정
public class WeeklyProductRankId implements Serializable { + private static final long serialVersionUID = 1L; + private Long refProductId; private String yearWeek;apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java (2)
19-23: 가중치 값에 대한 유효성 검증 고려가중치 값이 0 이상인지, 또는 합계가 1.0인지 검증하는 로직이 없습니다. 잘못된 설정 값이 주입될 경우 예상치 못한 랭킹 점수가 계산될 수 있습니다.
🔎 제안된 수정 예시
+import jakarta.validation.constraints.DecimalMin; +import org.springframework.validation.annotation.Validated; +@Validated @Getter @Setter @Component @ConfigurationProperties(prefix = "ranking") public class RankingBatchProperties { // ... @Getter @Setter public static class Weight { + @DecimalMin("0.0") private double view = 0.1; + @DecimalMin("0.0") private double like = 0.3; + @DecimalMin("0.0") private double order = 0.6; }
27-30: 배치 설정 값에 대한 유효성 검증 고려
chunkSize와limit이 0 이하의 값으로 설정되면 배치 작업이 예상대로 동작하지 않을 수 있습니다.@Min(1)등의 검증 어노테이션 추가를 고려해 주세요.apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (3)
4-4: 사용되지 않는 import 제거
@RequiredArgsConstructor가 import 되어 있지만, 필드들이@Autowired로 주입되므로 사용되지 않습니다.🔎 제안된 수정
-import lombok.RequiredArgsConstructor;
37-40: 빈@BeforeEach메서드 제거 권장현재
beforeEach()메서드가 비어있어 불필요한 코드입니다. 향후 setup 로직이 필요할 때 추가하시면 됩니다.🔎 제안된 수정
- @BeforeEach - void beforeEach() { - - }
42-44: 테스트 메서드명이 테스트 내용과 불일치
shouldNotSaveCategories_whenApiError는 API 에러 상황을 암시하지만, 실제로는requestDate파라미터 누락을 테스트합니다. 메서드명을 테스트 의도에 맞게 수정하는 것이 좋습니다.🔎 제안된 수정
- @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") - @Test - void shouldNotSaveCategories_whenApiError() throws Exception { + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldFail_whenRequestDateParameterMissing() throws Exception {apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java (2)
77-77: 배치 아이템 간 일관된 타임스탬프 사용 권장각 아이템 처리 시마다
LocalDateTime.now()를 호출하면 아이템 간 타임스탬프가 미세하게 다를 수 있습니다. 일관성이 필요하다면@BeforeStep에서 타임스탬프를 캡처하여 사용하는 것이 좋습니다.🔎 제안된 수정
beforeStep메서드에서 타임스탬프를 캡처:private LocalDate monthEnd; + private LocalDateTime processedAt; @BeforeStep public void beforeStep(StepExecution stepExecution) { // ... existing code ... + this.processedAt = LocalDateTime.now(); }
process메서드에서 사용:- LocalDateTime now = LocalDateTime.now(); - if (period == RankingPeriod.WEEKLY) { - return WeeklyProductRank.of(item.refProductId(), yearWeek, score, weekStart, weekEnd, now); + return WeeklyProductRank.of(item.refProductId(), yearWeek, score, weekStart, weekEnd, processedAt); } - return MonthlyProductRank.of(item.refProductId(), yearMonth, score, monthStart, monthEnd, now); + return MonthlyProductRank.of(item.refProductId(), yearMonth, score, monthStart, monthEnd, processedAt);
41-54: Job 파라미터 null 체크 누락
periodParam또는baseDateParam이 null일 경우RankingPeriod.fromCode(null)또는LocalDate.parse(null, ...)에서 예외가 발생할 수 있습니다. 명시적인 검증을 추가하면 더 명확한 에러 메시지를 제공할 수 있습니다.🔎 제안된 수정
@BeforeStep public void beforeStep(StepExecution stepExecution) { String periodParam = stepExecution.getJobParameters().getString("period"); String baseDateParam = stepExecution.getJobParameters().getString("baseDate"); + if (periodParam == null || baseDateParam == null) { + throw new IllegalArgumentException("period와 baseDate 파라미터는 필수입니다"); + } + this.period = RankingPeriod.fromCode(periodParam); this.baseDate = LocalDate.parse(baseDateParam, DATE_FORMATTER);apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java (1)
1-48: 공통 모듈로ProductMetricsId추출 권장
ProductMetricsId클래스가 두 모듈에 동일하게 정의되어 있습니다:
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java공통 도메인 모듈(예:
shared-domain또는core)을 생성하여 이 클래스를 추출하면 중복을 제거하고 유지보수성을 높일 수 있습니다. 현재 각 모듈이 독립적인 도메인 구조를 가지고 있으므로, 공유 타입에 대해 모듈 간 의존성을 정리하는 것을 고려해보세요.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java (1)
63-69:getRefProductId()접근자 누락
WeeklyProductRank와의 일관성 및ProductRankEntity인터페이스 구현을 위해getRefProductId()접근자가 필요할 수 있습니다. API 모듈의MonthlyProductRank에는 해당 메서드가 있습니다.🔎 제안된 수정
public String getYearMonth() { return yearMonth; } public Double getScore() { return score; } + + public Long getRefProductId() { + return refProductId; + } }apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java (1)
143-149: 주간 집계 테스트에서 점수 검증 어설션이 누락되었습니다.주석에 예상 점수가 명시되어 있지만 (
상품2가 더 높은 점수 (score = 200*0.1 + 20*0.3 + 10*0.6 = 32)), 실제로 점수 값을 검증하는 어설션이 없습니다. 점수 계산이 올바른지 확인하려면 어설션을 추가하는 것이 좋습니다.🔎 제안된 수정
() -> { List<WeeklyProductRank> ranks = weeklyRepository.findAll(); assertThat(ranks).hasSize(2); - // 상품2가 더 높은 점수 (score = 200*0.1 + 20*0.3 + 10*0.6 = 32) - // 상품1 합계 (score = 150*0.1 + 15*0.3 + 7*0.6 = 23.7) + // 상품2가 더 높은 점수 (score = 200*0.1 + 20*0.3 + 10*0.6 = 32) + // 상품1 합계 (score = 150*0.1 + 15*0.3 + 7*0.6 = 23.7) + WeeklyProductRank topRank = ranks.stream() + .max((a, b) -> a.getScore().compareTo(b.getScore())) + .orElseThrow(); + assertThat(topRank.getScore()).isEqualTo(32.0); }apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
40-47: 메서드 이름과 실제 용도가 불일치합니다.
categorySyncStep()메서드 이름이 상수STEP_DEMO_SIMPLE_TASK_NAME("demoSimpleTask") 및 실제 용도(데모)와 맞지 않습니다. 일관성을 위해 메서드 이름을demoSimpleTaskStep()등으로 변경하는 것이 좋습니다.🔎 제안된 수정
@JobScope @Bean(STEP_DEMO_SIMPLE_TASK_NAME) - public Step categorySyncStep() { + public Step demoSimpleTaskStep() { return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) .tasklet(demoTasklet, new ResourcelessTransactionManager()) .listener(stepMonitorListener) .build(); }Job 빌더에서 호출하는 부분도 함께 수정:
@Bean(JOB_NAME) public Job demoJob() { return new JobBuilder(JOB_NAME, jobRepository) .incrementer(new RunIdIncrementer()) - .start(categorySyncStep()) + .start(demoSimpleTaskStep()) .listener(jobListener) .build(); }apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)
14-26: 복합 키 파라미터 전달 구현이 올바릅니다.세 메서드 모두 새로운
metricDate파라미터를 JPA 레포지토리로 정확히 전달하고 있습니다. 복합 키 마이그레이션과 일관성 있게 구현되었습니다.선택사항: metricDate null 검증 추가 고려
현재
metricDate파라미터에 대한 null 검증이 없습니다. 상위 서비스 레이어에서 이미 검증하고 있다면 문제없지만, 방어적 프로그래밍을 위해 검증 추가를 고려할 수 있습니다:public void upsertLikeCount(Long productId, Integer metricDate, int delta, Long occurredAt) { + if (metricDate == null) { + throw new IllegalArgumentException("metricDate must not be null"); + } jpaRepository.upsertLikeCount(productId, metricDate, delta, occurredAt); }동일한 패턴을 다른 두 메서드에도 적용할 수 있습니다.
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)
12-12: @Modifying 어노테이션에 자동 플러시/클리어 옵션이 적절히 추가되었습니다.
clearAutomatically = true와flushAutomatically = true옵션은 네이티브 UPSERT 쿼리에서 JPA 엔티티 상태와의 일관성을 보장하기 위해 필요합니다.성능 고려사항: 이 옵션들은 영속성 컨텍스트를 플러시하고 클리어하므로 성능 영향이 있을 수 있습니다. 고빈도 호출 시나리오에서는 메트릭을 모니터링하는 것을 권장합니다.
Also applies to: 29-29, 46-46
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java (1)
37-59: 팩토리 메서드와 접근자가 올바르게 구현되었습니다.정적 팩토리 메서드가
ProductMetricsId를 올바르게 생성하고, getter 메서드들이 적절히 위임하고 있습니다.선택사항: 파라미터 검증 추가 고려
팩토리 메서드에서 null이나 음수 값을 검증하면 더 안전합니다:
public static ProductMetrics of(Long refProductId, Integer metricDate, Long viewCount, Long likeCount, Long salesCount) { + if (refProductId == null || metricDate == null) { + throw new IllegalArgumentException("refProductId and metricDate must not be null"); + } + if (viewCount < 0 || likeCount < 0 || salesCount < 0) { + throw new IllegalArgumentException("Counts must not be negative"); + } return new ProductMetrics(ProductMetricsId.of(refProductId, metricDate), viewCount, likeCount, salesCount); }apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)
38-41: 팩토리 메서드가 복합 키를 올바르게 생성합니다.
Math.max(delta, 0)로 음수 초기값을 방지하는 것이 좋습니다.ProductMetricsId.of()를 통해 복합 키를 정확히 생성합니다.선택사항: null 검증 추가 고려
public static ProductMetrics createWithLike(Long productId, Integer metricDate, int delta, Long occurredAt) { + if (productId == null || metricDate == null || occurredAt == null) { + throw new IllegalArgumentException("productId, metricDate, and occurredAt must not be null"); + } long initialLikeCount = Math.max(delta, 0); return new ProductMetrics(ProductMetricsId.of(productId, metricDate), initialLikeCount, 0L, 0L, occurredAt); }apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (1)
67-72: 연-주차 포맷팅 로직 중복이 있습니다.
toYearWeek메서드의 로직이RankingItemProcessor.initWeeklyFields()와 동일합니다. 두 곳에서 같은 포맷(%d-W%02d)과Locale.KOREAWeekFields를 사용하고 있어, 포맷 변경 시 불일치 위험이 있습니다.공통 유틸리티 클래스로 추출하여 일관성을 유지하는 것을 권장합니다.
🔎 유틸리티 클래스 추출 예시
public final class RankingDateUtils { private static final WeekFields KOREA_WEEK_FIELDS = WeekFields.of(Locale.KOREA); public static String toYearWeek(LocalDate date) { int weekBasedYear = date.get(KOREA_WEEK_FIELDS.weekBasedYear()); int weekOfYear = date.get(KOREA_WEEK_FIELDS.weekOfWeekBasedYear()); return String.format("%d-W%02d", weekBasedYear, weekOfYear); } }apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java (1)
131-146: 런타임 캐스팅에 대한 방어적 처리를 고려해주세요.
WeeklyProductRank.class::cast와MonthlyProductRank.class::cast가 실패하면ClassCastException이 발생합니다. Processor와 Writer가 동일한period파라미터를 사용하므로 정상 상황에서는 문제없지만, 디버깅을 위해 명확한 오류 메시지를 제공하면 좋습니다.🔎 방어적 캐스팅 예시
if (rankingPeriod == RankingPeriod.WEEKLY) { return items -> { List<WeeklyProductRank> ranks = items.getItems().stream() - .map(WeeklyProductRank.class::cast) + .map(item -> { + if (!(item instanceof WeeklyProductRank)) { + throw new IllegalStateException("Expected WeeklyProductRank but got " + item.getClass().getSimpleName()); + } + return (WeeklyProductRank) item; + }) .toList(); weeklyRepository.saveAll(ranks); log.debug("주간 점수 {} 건 저장 완료", ranks.size()); }; }
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (60)
README.mdapps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.javaapps/commerce-batch/build.gradle.ktsapps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.javaapps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.javaapps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.javaapps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.javaapps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.javaapps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.javaapps/commerce-batch/src/main/resources/application.ymlapps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.javaapps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.javaapps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.javaapps/commerce-batch/src/test/resources/sql/cleanup.sqlapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.javasettings.gradle.kts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-11-09T10:41:39.297Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 25
File: apps/commerce-api/src/main/kotlin/com/loopers/domain/product/ProductRepository.kt:1-12
Timestamp: 2025-11-09T10:41:39.297Z
Learning: In this codebase, domain repository interfaces are allowed to use Spring Data's org.springframework.data.domain.Page and org.springframework.data.domain.Pageable types. This is an accepted architectural decision and should not be flagged as a DIP violation.
Applied to files:
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.javaapps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java
📚 Learning: 2025-11-12T13:04:50.782Z
Learnt from: kilian-develop
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 72
File: core/domain/src/main/java/com/loopers/core/domain/productlike/vo/ProductLikeId.java:5-7
Timestamp: 2025-11-12T13:04:50.782Z
Learning: In the com.loopers codebase, domain entity ID value objects (e.g., ProductLikeId, OrderItemId, ProductId, PaymentId, OrderId, BrandId) are system-generated identifiers and do not require pattern validation (regex, length checks). They are implemented as simple records with a String value and an empty() factory method returning null for unsaved entities. This differs from UserIdentifier, which is a user-supplied login ID that requires format validation. Domain IDs should not be flagged for missing validation logic in the create() method.
<!-- [add_learning]
UserIdentifier와 같은 사용자 입력 ID와 ProductLikeId, OrderItemId 등의 도메인 ID는 검증 패턴이 다릅니다. UserIdentifier는 사용자가 입력하는 로그인 ID로서 정규식, 길이 등의 형식 검증이 필요하지만, 도메인 ID는 시스템에서 생성하는 식별자(UUID, DB 생성 ID)이므로 패턴 검증이 불필요합니다. 도메인 ID VO는 단순한 record와 empty() 팩토리 메서드만으로 충분합니다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.javaapps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java
📚 Learning: 2025-12-19T21:30:16.024Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-api/src/main/kotlin/com/loopers/infrastructure/outbox/OutboxEventListener.kt:0-0
Timestamp: 2025-12-19T21:30:16.024Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template Kafka event pipeline, Like events (LikeCreatedEventV1, LikeCanceledEventV1) intentionally use aggregateType="Like" with aggregateId=productId. The aggregateId serves as a partitioning/grouping key (not a unique Like entity identifier), ensuring all like events for the same product go to the same partition for ordering guarantees and aligning with ProductStatisticService's product-based aggregation logic. Using individual like_id would scatter events across partitions and break the statistics aggregation pattern.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.javaapps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java
📚 Learning: 2025-11-27T09:09:24.961Z
Learnt from: sky980221
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24
Timestamp: 2025-11-27T09:09:24.961Z
Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.javaapps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.javaapps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.javaapps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java
📚 Learning: 2025-11-21T03:38:07.494Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 99
File: apps/commerce-api/src/main/resources/application.yml:26-30
Timestamp: 2025-11-21T03:38:07.494Z
Learning: The batch job implementation for likeCount synchronization in apps/commerce-api is temporary and intended for development environment only. It will be replaced with Event-Driven Architecture (EDA) before production deployment, so production-level configuration concerns (like profile-based initialize-schema settings) are not required.
Applied to files:
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.javaapps/commerce-batch/src/main/resources/application.yml
🧬 Code graph analysis (19)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java (1)
WeeklyProductRankId(6-30)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java (1)
MonthlyProductRankId(6-30)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java (1)
WeeklyProductRankId(7-31)
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java (1)
MonthlyProductRankId(7-31)
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)
apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt (1)
main(22-24)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (3)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
Slf4j(9-21)apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
Slf4j(14-53)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(22-119)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (1)
Slf4j(9-21)apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
Slf4j(13-44)
apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java (1)
MetricDateConverter(7-23)
apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java (1)
SpringBootTest(6-10)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java (1)
Entity(11-70)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java (5)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java (1)
RequiredArgsConstructor(21-84)apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java (1)
Slf4j(15-51)apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
StepScope(14-32)apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java (1)
Configuration(18-38)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt (1)
log(22-119)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java (1)
Repository(10-20)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java (2)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java (1)
ApiHeaders(3-9)apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt (1)
result(7-19)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java (1)
Repository(10-20)
apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java (1)
MetricDateConverter(7-23)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java (1)
Entity(12-51)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
Slf4j(14-53)apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)
Slf4j(13-44)
apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java (1)
MetricDateConverter(7-23)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java (1)
Entity(11-70)
🔇 Additional comments (53)
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java (1)
1-10: 새로운 배치 모듈의 기본 테스트 구성이 적절합니다.Spring Boot 애플리케이션 컨텍스트 로딩을 검증하는 표준적인 스모크 테스트입니다. 빈 테스트 메서드는 의도적인 것으로, 컨텍스트 로딩이 실패하면 자동으로 테스트가 실패합니다. 새로운 commerce-batch 모듈의 기초 테스트로 적합합니다.
README.md (1)
30-30: LGTM!새로운 commerce-batch 모듈이 프로젝트 구조에 적절하게 추가되었습니다.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java (1)
3-8: LGTM!인터페이스 설계가 간결하고 명확합니다. JPA 프로젝션으로 사용될 것으로 보이며, 박싱된 타입(Long, Double) 사용이 적절합니다.
apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java (1)
14-38: LGTM!No-op Job 구현이 명확하고 적절합니다:
- Javadoc으로 목적이 잘 설명되어 있습니다
job.name이 지정되지 않았을 때의 기본 동작으로 적합합니다- 즉시 완료되는 tasklet 구현이 올바릅니다
settings.gradle.kts (1)
7-7: LGTM!새로운
commerce-batch모듈이 적절하게 추가되었습니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java (1)
1-8: LGTM!배치 모듈용
MonthlyProductRankJpaRepository가 JPA 기본 CRUD 메서드만 필요로 하는 쓰기 전용 목적에 맞게 간결하게 정의되었습니다.apps/commerce-batch/src/main/resources/application.yml (1)
1-36: LGTM!배치 모듈 설정이 잘 구성되어 있습니다. 가중치 합계가 1.0 (0.1 + 0.3 + 0.6)으로 정규화된 스코어링에 적합하며, 프로파일별
initialize-schema설정도 적절합니다.apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (1)
10-23: LGTM!배치 애플리케이션에 적합한 구현입니다.
SpringApplication.exit()을 사용하여 종료 코드를 반환하는 패턴은 스케줄러(Jenkins, Kubernetes Job 등)와의 연동에 중요하며, 타임존 설정도 시간 기반 랭킹 계산과 일관성을 유지합니다.apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)
25-52: LGTM!
afterJob메서드의 실행 시간 계산 및 포맷팅 로직이 적절합니다.apps/commerce-batch/build.gradle.kts (1)
1-22: LGTM!배치 모듈에 필요한 의존성이 적절하게 구성되었습니다. Spring Batch, JPA, Redis 및 QueryDSL 설정이 모두 포함되어 있으며, 수동 Job 실행 API를 위한
spring-boot-starter-web도 적절하게 추가되었습니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java (1)
1-4: LGTM!
WeeklyProductRank와MonthlyProductRank의 공통 마커 인터페이스로 적절합니다. 배치 처리 시 타입 안전성을 제공합니다.apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java (1)
1-8: LGTM!테스트용 JPA 리포지토리가 올바르게 정의되었습니다. 표준 Spring Data JPA 패턴을 따르고 있으며, E2E 테스트에서 ProductMetrics 엔티티 접근을 위한 적절한 구조입니다.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java (1)
1-9: LGTM!테스트 설정이 올바르게 구성되었습니다.
basePackageClasses를 사용하여 타입 안전한 방식으로 JPA 리포지토리 스캐닝 경로를 지정한 것은 좋은 관행입니다.apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java (1)
5-5: LGTM!
MetricDateConverter와RankingRedisProperties의존성 추가가 적절하게 이루어졌습니다. 타임존 기반 메트릭 날짜 계산을 위한 올바른 구조입니다.Also applies to: 7-7, 18-18
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java (1)
1-12: LGTM!인프라 레이어의 JPA 리포지토리가 올바르게 정의되었습니다. Spring Data의
Pageable타입을 사용하고, 쿼리 메서드 네이밍 규칙을 따르며, 복합 키MonthlyProductRankId를 적절히 활용하고 있습니다. 도메인 레이어와 인프라 레이어의 관심사 분리가 잘 이루어졌습니다.apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java (1)
5-8: 코드 구조가 적절합니다.도메인 레이어의 리포지토리 인터페이스가 깔끔하게 정의되어 있고, 수동 페이지네이션 파라미터를 사용하는 것도 적절합니다.
apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java (1)
28-30: 메트릭 날짜 기반 집계로의 전환이 적절합니다.
MetricDateConverter를 사용하여 이벤트 발생 시각을 메트릭 날짜로 변환하고, 이를 기반으로 판매 수량을 업서트하는 로직이 올바르게 구현되었습니다. 타임존 설정도RankingRedisProperties를 통해 관리되어 일관성이 확보됩니다.apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java (1)
7-8: 코드가 올바른 아키텍처 설계를 따르고 있습니다. 추가 메서드가 필요하지 않습니다.
WeeklyProductRankJpaRepository(commerce-batch)와WeeklyProductRankRepository(commerce-api의 도메인 계층)는 서로 다른 목적의 리포지토리입니다:
- commerce-batch의 JPA 리포지토리:
saveAll(),findAll()만 사용하며 이는 JpaRepository에서 상속받은 기본 CRUD 연산입니다. 커스텀 쿼리 메서드가 필요 없습니다.- commerce-api의 도메인 리포지토리: 비즈니스 로직을 위한
findByYearWeekOrderByScoreDesc메서드를 정의합니다.두 계층이 분리되어 있어 commerce-batch의 빈 인터페이스는 의도된 설계이며 올바릅니다.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java (1)
9-12: LGTM!Spring Data JPA 쿼리 메서드가 올바르게 정의되어 있습니다.
Pageable과List반환 타입의 조합은 전체 count 쿼리 없이 페이지네이션을 적용하는 효율적인 패턴입니다.WeeklyProductRankRepositoryImpl의 구현 패턴과도 일관성이 있습니다.apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java (1)
34-36: LGTM!
RankingRedisProperties를 주입하여 타임존 설정을 사용하는 것은 프로덕션 코드와 일관성을 유지하는 좋은 접근입니다.apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java (1)
6-29: LGTM!JPA
@IdClass용 복합 키 클래스가 올바르게 구현되어 있습니다.Serializable인터페이스, 기본 생성자,equals()/hashCode()오버라이드가 모두 적절합니다.WeeklyProductRankId와 일관된 패턴을 따르고 있습니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java (1)
6-30: LGTM!배치 모듈의
WeeklyProductRank엔티티를 위한 복합 키 클래스입니다. commerce-api 모듈의 MV 레이어에도 유사한 클래스가 있지만, 모듈 간 독립성을 위해 별도로 정의하는 것은 적절한 설계입니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java (1)
10-19: LGTM!
WeeklyProductRankRepositoryImpl과 동일한 패턴을 따르는 깔끔한 구현입니다. 도메인 인터페이스와 인프라 구현의 분리가 적절하며,PageRequest를 사용한 페이지네이션 처리가 올바릅니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java (1)
6-30: LGTM!배치 모듈의
MonthlyProductRank엔티티를 위한 복합 키 클래스입니다. 동일 패키지의WeeklyProductRankId와 일관된 구현 패턴을 따르고 있습니다.apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java (1)
12-58: LGTM!MV 엔티티 구조가 적절합니다. 복합 키 매핑, 읽기 전용 설계, 그리고
ProductRankView인터페이스 구현이 일관성 있게 작성되었습니다.apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java (1)
10-19: LGTM!
MonthlyProductRankRepositoryImpl과 일관된 구조로 구현되었으며, JPA 리포지토리에 대한 위임 패턴이 깔끔합니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java (1)
11-70: LGTM!
MonthlyProductRank와 일관된 구조로 구현되었습니다. 팩토리 메서드 패턴과 불변 설계가 적절합니다.참고:
commerce-api와commerce-batch모듈에 동일한 테이블을 매핑하는 엔티티가 각각 존재합니다. 현재는 모듈 간 독립성 유지를 위한 의도적 설계로 보이나, 향후 공통 모듈로 추출하여 중복을 줄이는 것도 고려해 볼 수 있습니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java (1)
40-65: LGTM!주간/월간 랭킹 엔드포인트가 기존 일간 랭킹 엔드포인트와 일관된 패턴으로 구현되었습니다. 파라미터 검증 및 응답 처리가 적절합니다.
apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java (1)
25-50: LGTM!Job 실행 파사드 구현이 적절합니다.
runId를 통한 재실행 지원, 구조화된 결과 반환, 그리고 적절한 로깅이 잘 구현되어 있습니다.apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java (1)
25-30: LGTM!
MetricDateConverter를 활용한 timezone 기반 메트릭 날짜 변환이 깔끔하게 통합되었습니다.RankingRedisProperties에서 timezone 설정을 주입받아 일관된 날짜 처리를 보장합니다.apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java (1)
56-62: Locale.KOREA는 ISO 8601 표준을 따르므로 월요일을 주의 첫 날로 사용합니다.
WeekFields.of(Locale.KOREA)는 일요일이 아닌 월요일을 주의 시작으로 사용하므로,yearWeek계산과weekStart/weekEnd계산이 모두 월요일-일요일 기준으로 일관성 있게 작동합니다. 코드는 올바르게 구현되어 있습니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java (1)
11-51: 엔티티 구조가 적절합니다.JPA 복합 키 패턴을 올바르게 사용하고 있으며,
WeeklyProductRank와 일관된 구조를 유지하고 있습니다. 팩토리 메서드 패턴과 protected 기본 생성자 사용도 적절합니다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java (1)
5-31: LGTM!Enum 구조가 깔끔하고,
fromCode()메서드에서 대소문자 구분 없이 매칭하는 것은 사용성 측면에서 좋습니다. 에러 메시지도 명확합니다.apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java (1)
21-35: API 스펙이 일관성 있게 정의되었습니다.주간/월간 랭킹 API가 일간 랭킹과 동일한 파라미터 패턴을 따르고 있습니다. TOP 100 제한 사항이 API 문서에 명확하게 표시되어 있습니다.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java (1)
12-51: 읽기 전용 MV 엔티티로 적절하게 설계되었습니다.API 모듈에서 MV(Materialized View) 데이터를 읽기 전용으로 조회하는 용도에 맞게 설계되었습니다. 팩토리 메서드나 setter 없이 JPA 조회 전용으로 사용하는 것이 적절합니다.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java (1)
59-114: 파라미터 검증 테스트가 잘 구성되어 있습니다.누락된 파라미터와 유효하지 않은 값에 대한 테스트 케이스가 포괄적으로 작성되어 있습니다.
@Nested클래스를 사용한 테스트 구조화도 가독성을 높여줍니다.apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
19-38: 데모 Job 구성이 적절합니다.
@ConditionalOnProperty를 사용하여 필요할 때만 빈을 생성하는 패턴과RunIdIncrementer를 사용한 재실행 지원이 적절합니다.apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (3)
30-33: 공통 로직 추출이 잘 되었습니다.
buildResult헬퍼 메서드로 리팩토링하여 코드 중복을 제거했습니다. 기존 동작을 유지하면서 깔끔하게 개선되었습니다.
35-45: 주간/월간 랭킹 조회 메서드가 일관성 있게 구현되었습니다.두 메서드 모두 일일 랭킹 조회와 동일한 패턴을 따르며,
@Transactional(readOnly = true)로 적절히 표시되어 있습니다.
47-89: 배치 조회 패턴으로 N+1 문제를 효과적으로 방지했습니다.
buildResult헬퍼 메서드가 상품, 브랜드, 좋아요 상태를 배치로 조회하여 성능을 최적화했습니다. 삭제된 상품은 null 필터링으로 안전하게 제거되며, 이 동작은shouldExcludeDeletedProducts테스트로 검증되었으므로 의도된 설계입니다.apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (2)
10-10: 복합 키 타입으로 올바르게 변경되었습니다.
JpaRepository의 ID 타입이Long에서ProductMetricsId로 변경되어 복합 키 마이그레이션과 일치합니다.
16-27: upsertSalesCount와 upsertViewCount는 음수 메트릭 값 방지 로직이 누락되었습니다.upsertLikeCount는
GREATEST(:delta, 0)으로 음수 값을 방지하지만, upsertSalesCount와 upsertViewCount는 INSERT와 UPDATE에서 GREATEST() 함수 없이 직접 파라미터를 사용합니다:
- upsertSalesCount (라인 34, 36):
:quantity,sales_count + :quantity→ 음수 가능- upsertViewCount (라인 51, 53):
:count,view_count + :count→ 음수 가능메트릭 값이 음수가 되지 않도록 다음과 같이 수정하세요:
-- upsertSalesCount INSERT INTO product_metrics (ref_product_id, metric_date, like_count, sales_count, view_count, updated_at) VALUES (:productId, :metricDate, 0, GREATEST(:quantity, 0), 0, :occurredAt) ON DUPLICATE KEY UPDATE sales_count = GREATEST(sales_count + :quantity, 0), updated_at = GREATEST(updated_at, :occurredAt) -- upsertViewCount INSERT INTO product_metrics (ref_product_id, metric_date, like_count, sales_count, view_count, updated_at) VALUES (:productId, :metricDate, 0, 0, GREATEST(:count, 0), :occurredAt) ON DUPLICATE KEY UPDATE view_count = GREATEST(view_count + :count, 0), updated_at = GREATEST(updated_at, :occurredAt)⛔ Skipped due to learnings
Learnt from: sky980221 Repo: Loopers-dev-lab/loopers-spring-java-template PR: 121 File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java:22-24 Timestamp: 2025-11-27T09:09:24.961Z Learning: Product 엔티티 (apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java)는 유즈케이스별로 의도적으로 다른 락 전략을 사용한다: 좋아요 기능에는 비관적 락(findByIdForUpdate)을, 재고 차감에는 낙관적 락(Version + 재시도)을 사용한다.apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java (1)
8-26: 배치 모듈의 읽기 전용 엔티티가 올바르게 정의되었습니다.
@EmbeddedId를 사용한 복합 키 구조와 non-nullable 필드 선언이 적절합니다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (4)
12-13: 복합 키로의 마이그레이션이 올바르게 적용되었습니다.
@Id대신@EmbeddedId를 사용하여(ref_product_id, metric_date)복합 키 구조를 구현했습니다.
29-36: 생성자가 복합 키 구조에 맞게 업데이트되었습니다.
ProductMetricsId파라미터를 받아 필드를 올바르게 초기화합니다.
43-53: 접근자 메서드가 임베디드 키 구조에 맞게 잘 구현되었습니다.
getId(),getRefProductId(),getMetricDate()모두 복합 키 컴포넌트에 대한 편리한 접근을 제공합니다.
1-70: 이 리뷰 코멘트는 부정확합니다. ProductMetrics는 원래부터 불변이었습니다.제거된 메서드(
incrementLikeCount,decrementLikeCount,updateTimestamp)는 ProductMetrics에 존재한 적이 없습니다. 이 메서드들은 commerce-api 모듈의 Product 엔티티에만 존재하며, ProductMetrics(commerce-streamer 모듈)와는 별개의 엔티티입니다.ProductMetrics는 처음부터 불변 설계를 따르며, 리포지토리는
upsertLikeCount(),upsertSalesCount(),upsertViewCount()등의 upsert 메서드로 데이터 변경을 처리합니다. 따라서 코드 변경으로 인한 영향 범위나 마이그레이션이 필요하지 않습니다.Likely an incorrect or invalid review comment.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (2)
35-45: 새로운 주간/월간 랭킹 조회 메서드가 적절하게 구현되었습니다.
getWeeklyTopN과getMonthlyTopN메서드가 각각의 MV 레포지토리에서 데이터를 조회하고RankingEntry로 변환하는 흐름이 명확합니다.
47-55: 페이지 인덱싱 방식은 0-based로 올바르게 구현되어 있습니다.RankingController에서
@RequestParam(defaultValue = "0") @Min(0)로 명시적으로 0-based 페이지 인덱싱을 사용 중이므로, RankingService의baseRank = page * size계산은 정상입니다. 예를 들어:
- page=0, size=10: rank는 1~10
- page=1, size=10: rank는 11~20
따라서 추가 수정이 필요 없습니다.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java (3)
52-58: Job 정의가 적절합니다.
rankingAggregationStep(null, null)호출은 Spring Batch의 표준 패턴으로, 실제 실행 시@JobScope프록시를 통해 job parameters가 주입됩니다.
85-100: JPQL ORDER BY와 Processor의 점수 계산 로직 일관성 확인이 필요합니다.Reader의 ORDER BY 절과
RankingItemProcessor.process()에서 동일한 가중치(viewWeight,likeWeight,orderWeight)를 사용하여 점수를 계산합니다. 만약 두 곳의 가중치 설정이 다르다면 순위 불일치가 발생할 수 있습니다.현재 둘 다
RankingBatchProperties를 참조하므로 일관성이 유지되지만, 이 관계가 명시적으로 문서화되거나 테스트로 보장되면 좋겠습니다.
149-162: 날짜 범위 계산 로직이 정확합니다.
TemporalAdjusters를 사용한 주간/월간 경계 계산이 올바르게 구현되었고,yyyyMMddInteger 형식이ProductMetrics의 composite key인metricDate(Integer 타입)와 일치합니다. 형식 일관성이 확인되었습니다.apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)
3-9: 복합 키 지원을 위한 인터페이스 변경이 적절하게 구현되었습니다.모든 upsert 메서드에
metricDate파라미터가 추가되어 날짜별 메트릭 집계를 지원합니다. 파라미터 순서(productId,metricDate, value,occurredAt)가 모든 메서드에서 일관성 있게 유지되었으며, ProductMetricsRepositoryImpl의 구현체와 ProductLikedStrategy, ProductSoldStrategy, ProductUnlikedStrategy, ProductViewedStrategy의 모든 호출 코드가 새 시그니처에 맞게 정확하게 업데이트되었습니다.
| private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) { | ||
| this.id = id; | ||
| this.viewCount = viewCount; | ||
| this.likeCount = likeCount; | ||
| this.salesCount = salesCount; | ||
| this.updatedAt = System.currentTimeMillis(); | ||
| } |
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.
읽기 전용 엔티티에서 updatedAt을 현재 시간으로 설정하는 것은 부적절합니다.
Line 34에서 updatedAt = System.currentTimeMillis()로 설정하고 있는데, 이는 배치 읽기 전용 엔티티이므로 데이터베이스의 실제 값을 반영하지 않습니다.
🔎 제안하는 수정안
private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) {
this.id = id;
this.viewCount = viewCount;
this.likeCount = likeCount;
this.salesCount = salesCount;
- this.updatedAt = System.currentTimeMillis();
}JPA가 데이터베이스에서 로드한 값을 그대로 사용하도록 하거나, 명시적으로 updatedAt을 파라미터로 받도록 변경하는 것을 권장합니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) { | |
| this.id = id; | |
| this.viewCount = viewCount; | |
| this.likeCount = likeCount; | |
| this.salesCount = salesCount; | |
| this.updatedAt = System.currentTimeMillis(); | |
| } | |
| private ProductMetrics(ProductMetricsId id, Long viewCount, Long likeCount, Long salesCount) { | |
| this.id = id; | |
| this.viewCount = viewCount; | |
| this.likeCount = likeCount; | |
| this.salesCount = salesCount; | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java
around lines 29-35, the private constructor unconditionally sets updatedAt =
System.currentTimeMillis(), which overwrites DB-loaded value for this read-only
batch entity; remove that hard-coded assignment and instead either (A) add
updatedAt as a constructor parameter and assign it from the caller (so DB/JPA
can provide the real timestamp), or (B) remove any assignment to updatedAt in
this constructor so the field retains the value populated by JPA; update all
constructor callers to pass the actual updatedAt when you choose option A.
| // 인증/권한 체크 필요 | ||
| @RestController | ||
| @RequestMapping("/api/v1/jobs") | ||
| @RequiredArgsConstructor | ||
| public class BatchJobController { | ||
|
|
||
| private final BatchJobFacade batchJobFacade; | ||
|
|
||
| @PostMapping("/ranking") | ||
| public ResponseEntity<JobExecutionResult> runRankingAggregation( | ||
| @RequestParam RankingPeriod period, | ||
| @RequestParam @DateTimeFormat(pattern = "yyyyMMdd") LocalDate date | ||
| ) { | ||
| return ResponseEntity.ok(batchJobFacade.runRankingAggregation(period, date)); | ||
| } |
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.
인증/권한 체크 미구현 상태
주석에 "인증/권한 체크 필요"라고 명시되어 있으나 구현되지 않았습니다. 배치 작업 트리거 엔드포인트는 보안에 민감하므로, 운영 환경 배포 전에 반드시 인증/권한 체크를 구현해야 합니다.
추가로, 동일한 기간에 대해 중복 실행 방지 로직도 고려해 보세요.
인증/권한 체크 구현을 도와드릴까요? 또는 별도 이슈로 추적할 수 있도록 생성해 드릴까요?
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java
around lines 15-29, the controller lacks implemented
authentication/authorization and duplicate-execution protection despite the
comment; add security by annotating the controller or endpoint with the
appropriate Spring Security constraints (e.g., @PreAuthorize or a secured role)
or apply a global security config to restrict access to trusted roles, extract
caller identity from the SecurityContext and validate scope/roles before
invoking batchJobFacade; additionally implement idempotency/duplicate-run
protection by checking and rejecting concurrent or repeated requests for the
same period+date (e.g., consult a persistent lock/status table or use a
distributed lock keyed by period+date, return conflict/accepted responses
accordingly) so that duplicate executions are prevented.
| public double calculateScore(double viewWeight, double likeWeight, double orderWeight) { | ||
| return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight); | ||
| } |
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.
Long 필드의 null 값에 대한 방어 코드가 필요합니다.
totalViewCount, totalLikeCount, totalSalesCount 필드가 Long 타입이므로 null일 경우 NullPointerException이 발생할 수 있습니다. DB 쿼리 결과에서 집계 값이 없을 때 null이 반환될 수 있습니다.
🔎 null 방어 코드 제안
public double calculateScore(double viewWeight, double likeWeight, double orderWeight) {
- return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight);
+ long views = totalViewCount != null ? totalViewCount : 0L;
+ long likes = totalLikeCount != null ? totalLikeCount : 0L;
+ long sales = totalSalesCount != null ? totalSalesCount : 0L;
+ return (views * viewWeight) + (likes * likeWeight) + (sales * orderWeight);
}또는 record의 compact constructor에서 null을 0으로 정규화하는 방법도 있습니다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public double calculateScore(double viewWeight, double likeWeight, double orderWeight) { | |
| return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight); | |
| } | |
| public double calculateScore(double viewWeight, double likeWeight, double orderWeight) { | |
| long views = totalViewCount != null ? totalViewCount : 0L; | |
| long likes = totalLikeCount != null ? totalLikeCount : 0L; | |
| long sales = totalSalesCount != null ? totalSalesCount : 0L; | |
| return (views * viewWeight) + (likes * likeWeight) + (sales * orderWeight); | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java
around lines 10-12, the calculateScore method multiplies Long fields that can be
null, risking NullPointerException; update the class to defensively handle nulls
by normalizing totalViewCount, totalLikeCount, and totalSalesCount to 0L (either
in the record's compact constructor or by coalescing to 0L inside calculateScore
before arithmetic) so the method uses primitive long values (or their
longValue() of non-null) and then compute the score using those non-null values.
| @AfterChunk | ||
| void afterChunk(ChunkContext chunkContext) { | ||
| log.info( | ||
| "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + | ||
| "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" | ||
| ); | ||
| } |
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.
잘못된 문자열 보간 구문으로 로그가 올바르게 출력되지 않음
Java와 SLF4J는 ${} 문자열 보간 구문을 지원하지 않습니다. 현재 코드는 실제 값 대신 리터럴 문자열 "${chunkContext.stepContext.stepExecution.readCount}"를 출력합니다. SLF4J의 {} 플레이스홀더를 사용해야 합니다.
참고: JobListener.java의 line 18에도 동일한 문제가 있습니다.
🔎 제안된 수정
@AfterChunk
void afterChunk(ChunkContext chunkContext) {
log.info(
- "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " +
- "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}"
+ "청크 종료: readCount: {}, writeCount: {}",
+ chunkContext.getStepContext().getStepExecution().getReadCount(),
+ chunkContext.getStepContext().getStepExecution().getWriteCount()
);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @AfterChunk | |
| void afterChunk(ChunkContext chunkContext) { | |
| log.info( | |
| "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + | |
| "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" | |
| ); | |
| } | |
| @AfterChunk | |
| void afterChunk(ChunkContext chunkContext) { | |
| log.info( | |
| "청크 종료: readCount: {}, writeCount: {}", | |
| chunkContext.getStepContext().getStepExecution().getReadCount(), | |
| chunkContext.getStepContext().getStepExecution().getWriteCount() | |
| ); | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
around lines 14 to 20 (and similarly in JobListener.java at line 18), the log
uses Java-style `${}` interpolation which SLF4J does not support; update the log
to use SLF4J '{}' placeholders and pass the actual values as parameters (e.g.,
obtain readCount and writeCount from chunkContext.stepContext.stepExecution and
supply them as arguments to the logger) so the numeric values are printed
correctly.
| @BeforeJob | ||
| void beforeJob(JobExecution jobExecution) { | ||
| log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); | ||
| jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); | ||
| } |
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.
Kotlin 문자열 보간 문법 오류 - 로그가 올바르게 출력되지 않습니다.
Line 21의 "${jobExecution.jobInstance.jobName}"은 Kotlin 문법입니다. Java에서는 이 구문이 변수로 치환되지 않고 리터럴 문자열로 출력됩니다.
🔎 수정 제안
@BeforeJob
void beforeJob(JobExecution jobExecution) {
- log.info("Job '${jobExecution.jobInstance.jobName}' 시작");
+ log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName());
jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis());
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @BeforeJob | |
| void beforeJob(JobExecution jobExecution) { | |
| log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); | |
| jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); | |
| } | |
| @BeforeJob | |
| void beforeJob(JobExecution jobExecution) { | |
| log.info("Job '{}' 시작", jobExecution.getJobInstance().getJobName()); | |
| jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); | |
| } |
🤖 Prompt for AI Agents
In apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
around lines 19 to 23, the log uses Kotlin-style string interpolation
("${jobExecution.jobInstance.jobName}") which prints literally in Java; replace
it with a proper Java/logging call such as using SLF4J placeholder or string
concatenation and the Java getters (e.g., log.info("Job '{}' 시작",
jobExecution.getJobInstance().getJobName()); or log.info("Job '" +
jobExecution.getJobInstance().getJobName() + "' 시작");) so the actual job name is
logged.
| log.info( | ||
| """ | ||
| [에러 발생] | ||
| jobName: {} | ||
| exceptions: | ||
| {} | ||
| """.trim(), jobName, exceptions | ||
| ); |
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.
에러 로깅 레벨을 수정하세요.
에러 정보를 로깅할 때 log.info() 대신 log.error()를 사용해야 합니다. 현재 방식은 에러가 발생해도 INFO 레벨로 기록되어 모니터링과 알림 시스템에서 적절히 감지되지 않을 수 있습니다.
🔎 로깅 레벨 수정 제안
- log.info(
+ log.error(
"""
[에러 발생]
jobName: {}
exceptions:
{}
""".trim(), jobName, exceptions
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| log.info( | |
| """ | |
| [에러 발생] | |
| jobName: {} | |
| exceptions: | |
| {} | |
| """.trim(), jobName, exceptions | |
| ); | |
| log.error( | |
| """ | |
| [에러 발생] | |
| jobName: {} | |
| exceptions: | |
| {} | |
| """.trim(), jobName, exceptions | |
| ); |
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
around lines 31 to 38, the error message is being logged with log.info();
replace that call with log.error(...) so the message is recorded at ERROR level;
ensure the method call signature still passes jobName and the exceptions (or
pass the Throwable as the last parameter) so the exception stacktrace is
included in the error log.
| public void handle(Long productId, Long occurredAt, JsonNode payload) { | ||
| productMetricsRepository.upsertLikeCount(productId, 1, occurredAt); | ||
| log.debug("상품 {} 좋아요 수 증가", productId); | ||
| Integer metricDate = MetricDateConverter.toMetricDate(occurredAt, rankingProperties.getTimezone()); | ||
| productMetricsRepository.upsertLikeCount(productId, metricDate, 1, occurredAt); | ||
| log.debug("상품 {} 좋아요 수 증가 (날짜: {})", productId, metricDate); | ||
| } |
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
🏁 Script executed:
find . -name "*.java" -type f | head -20Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1800
🏁 Script executed:
# Search for CatalogEventStrategy interface and handle() method invocations
rg -nP --type=java 'CatalogEventStrategy|interface.*Strategy' -A 5 | head -50Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 5841
🏁 Script executed:
# Search for all calls to handle() method with parameters
rg -nP --type=java '\.handle\s*\(\s*\w+\s*,\s*\w+\s*,' -B 2 -A 2 | head -100Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3374
🏁 Script executed:
# Search for ProductLikedStrategy instantiation and usage
rg -nP --type=java 'ProductLikedStrategy|handle\(' -B 3 -A 3 | head -80Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 9452
🏁 Script executed:
# Search for @NonNull, @NotNull annotations on parameters
rg -nP --type=java '(@NonNull|@NotNull).*occurredAt|handle.*occurredAt' -B 2 -A 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 9425
🏁 Script executed:
cat -n apps/commerce-streamer/src/main/java/com/loopers/application/CatalogEventHandler.java | head -85Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 3656
🏁 Script executed:
# Search for any null checks or validation of occurredAt in CatalogEventHandler
rg -nP --type=java 'occurredAt.*null|null.*occurredAt|Objects\.requireNonNull.*occurredAt' -B 2 -A 2Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 70
🏁 Script executed:
# Search for callers of CatalogEventHandler.handle() method
rg -nP --type=java 'catalogEventHandler\.handle|CatalogEventHandler.*handle' -B 2 -A 2 | head -100Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 12325
🏁 Script executed:
# Check if CatalogEventEnvelope has null annotations on occurredAt
rg -nP --type=java 'class CatalogEventEnvelope|record CatalogEventEnvelope' -A 10 | head -30Repository: Loopers-dev-lab/loopers-spring-java-template
Length of output: 1307
occurredAt 파라미터 null 안전성 검증 필요
CatalogEventHandler.handle() 메서드는 occurredAt을 검증 없이 전달하며, CatalogEventEnvelope.occurredAt도 null 안전 어노테이션이 없습니다. MetricDateConverter.toMetricDate()는 null 입력 시 IllegalArgumentException을 발생시키므로, 상위 호출자에서 null이 전달될 경우 예외가 발생합니다. 현재 테스트와 실제 구현에서는 항상 유효한 타임스탐프를 전달하지만, 방어적 프로그래밍을 위해 다음 중 하나를 검토해 주세요:
CatalogEventEnvelope.occurredAt필드에@NotNull어노테이션 추가CatalogEventHandler.handle()메서드에서 null 검증 로직 추가ProductLikedStrategy.handle()메서드에@NonNull파라미터 어노테이션 추가
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java
around lines 26-30, the method accepts occurredAt which may be null and will
cause MetricDateConverter.toMetricDate() to throw; add a defensive null-safety
change: at the start of handle(Long productId, Long occurredAt, JsonNode
payload) validate occurredAt (e.g., if occurredAt == null) and either throw an
IllegalArgumentException with a clear message or log an error and return, so
null never reaches MetricDateConverter; alternatively, make the contract
explicit by adding a @NonNull annotation to the occurredAt parameter or add
@NotNull to CatalogEventEnvelope.occurredAt in the upstream model—pick one
consistent approach and apply it across callers and tests.
| public static Integer toMetricDate(Long epochMillis, ZoneId zoneId) { | ||
| if (epochMillis == null) { | ||
| throw new IllegalArgumentException("epochMillis는 null일 수 없습니다"); | ||
| } | ||
| String dateStr = Instant.ofEpochMilli(epochMillis) | ||
| .atZone(zoneId) | ||
| .toLocalDate() | ||
| .format(DATE_FORMATTER); | ||
| return Integer.parseInt(dateStr); | ||
| } |
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.
zoneId null 체크 누락
epochMillis는 null 체크가 있지만 zoneId가 null일 경우 atZone(zoneId)에서 NullPointerException이 발생합니다. 일관된 방어적 프로그래밍을 위해 zoneId도 검증하는 것이 좋습니다.
🔎 제안된 수정
public static Integer toMetricDate(Long epochMillis, ZoneId zoneId) {
if (epochMillis == null) {
throw new IllegalArgumentException("epochMillis는 null일 수 없습니다");
}
+ if (zoneId == null) {
+ throw new IllegalArgumentException("zoneId는 null일 수 없습니다");
+ }
String dateStr = Instant.ofEpochMilli(epochMillis)
.atZone(zoneId)
.toLocalDate()
.format(DATE_FORMATTER);
return Integer.parseInt(dateStr);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public static Integer toMetricDate(Long epochMillis, ZoneId zoneId) { | |
| if (epochMillis == null) { | |
| throw new IllegalArgumentException("epochMillis는 null일 수 없습니다"); | |
| } | |
| String dateStr = Instant.ofEpochMilli(epochMillis) | |
| .atZone(zoneId) | |
| .toLocalDate() | |
| .format(DATE_FORMATTER); | |
| return Integer.parseInt(dateStr); | |
| } | |
| public static Integer toMetricDate(Long epochMillis, ZoneId zoneId) { | |
| if (epochMillis == null) { | |
| throw new IllegalArgumentException("epochMillis는 null일 수 없습니다"); | |
| } | |
| if (zoneId == null) { | |
| throw new IllegalArgumentException("zoneId는 null일 수 없습니다"); | |
| } | |
| String dateStr = Instant.ofEpochMilli(epochMillis) | |
| .atZone(zoneId) | |
| .toLocalDate() | |
| .format(DATE_FORMATTER); | |
| return Integer.parseInt(dateStr); | |
| } |
🤖 Prompt for AI Agents
In
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java
around lines 13 to 22, add a null check for the zoneId parameter like the
existing epochMillis check: if zoneId is null, throw an IllegalArgumentException
with a clear message (match the existing Korean style, e.g., "zoneId는 null일 수
없습니다"); keep the rest of the method unchanged so atZone(zoneId) never receives
null.
- ProductMetrics.of()에 updatedAt 파라미터 추가 - AggregatedProductScore compact constructor에서 null -> 0L 정규화 - DateRange 날짜 유효성 검증 추가 (startDate <= endDate, YYYYMMDD 형식) - RankingItemProcessor Job 파라미터 null 체크 및 배치 시작 시간 통일 - WeeklyProductRankId serialVersionUID 추가 - 팩토리 메서드 파라미터 null 검증 추가 (batch/streamer) - 페이지네이션 파라미터 경계 조건 검증 추가 - 테스트 고정 시간 사용 및 점수 검증 assertion 강화 - cleanup.sql DELETE -> TRUNCATE 변경 - RankingService Locale.KOREA -> Locale.getDefault() 변경
|
@CodeRabbit
|
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: Length of output: 4478 🏁 Script executed: Length of output: 4411 🏁 Script executed: Length of output: 4926 🏁 Script executed: Length of output: 2484 🏁 Script executed: Length of output: 1675 @JeonJe 리뷰 피드백 반영 내용을 확인했습니다! 🎉 ✅ 검증 결과1. ProductMetrics.of() updatedAt 파라미터 추가 ✅public static ProductMetrics of(
Long refProductId,
Integer metricDate,
Long viewCount,
Long likeCount,
Long salesCount,
Long updatedAt) // ✅ 추가됨2. AggregatedProductScore null 정규화 ✅public AggregatedProductScore {
totalViewCount = totalViewCount != null ? totalViewCount : 0L;
totalLikeCount = totalLikeCount != null ? totalLikeCount : 0L;
totalSalesCount = totalSalesCount != null ? totalSalesCount : 0L;
}3. DateRange 유효성 검증 ✅public DateRange {
validateDateFormat(startDate, "시작일");
validateDateFormat(endDate, "종료일");
if (startDate > endDate) {
throw new IllegalArgumentException("시작일은 종료일보다 클 수 없습니다");
}
}4. RankingItemProcessor 개선 ✅
@BeforeStep
public void beforeStep(StepExecution stepExecution) {
if (periodParam == null || baseDateParam == null) {
throw new IllegalArgumentException("period와 baseDate 파라미터는 필수입니다");
}
this.batchStartTime = LocalDateTime.now(); // ✅ 한 번만 설정
// ...
}5. WeeklyProductRankId serialVersionUID ✅private static final long serialVersionUID = 1L;6. 팩토리 메서드 null 검증 ✅Streamer (ProductMetrics): public static ProductMetrics createWithLike(Long productId, Integer metricDate, int delta, Long occurredAt) {
if (productId == null || metricDate == null || occurredAt == null) {
throw new IllegalArgumentException("productId, metricDate, occurredAt은 필수입니다");
}
// ...
}Batch (WeeklyProductRank, MonthlyProductRank): 7. 페이지네이션 경계 조건 검증 ✅@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size8. 테스트 개선 ✅
assertThat(ranks.get(0).getScore()).isEqualTo(16.0);
// view(100)*0.1 + like(10)*0.3 + sales(5)*0.6 = 16.09. cleanup.sql TRUNCATE 변경 ✅TRUNCATE TABLE mv_product_rank_weekly;
TRUNCATE TABLE mv_product_rank_monthly;
TRUNCATE TABLE product_metrics;10. RankingService Locale 변경 ✅WeekFields weekFields = WeekFields.of(Locale.getDefault());모든 피드백이 완벽하게 반영되었습니다! 코드 품질과 안정성이 크게 향상되었습니다. 👏 🧠 Learnings usedIf you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
- RankingScheduler 추가: 주간(월 02:00), 월간(1일 03:00) 자동 실행 - @EnableScheduling 활성화 - Reader ORDER BY score → ORDER BY id 변경 (역할 분리) - Reader: 조회/집계만, Processor: 점수 계산
Round 10 - Spring Batch 기반 주간/월간 랭킹 시스템 구축
Summary
10주차 Spring-Batch와 MV로 주간/월간 기능을 구현하였습니다.
product_metrics일간 데이터를 집계하여 주간/월간 상품별 점수 저장mv_product_rank_weekly,mv_product_rank_monthly)에 집계 결과 저장Checklist
Spring Batch
Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
RankingAggregationJobConfig에서period,baseDate파라미터로 Job 실행Chunk Oriented Processing 기반의 배치 처리를 구현했다.
JpaPagingItemReader(GROUP BY + SUM 집계 쿼리)RankingItemProcessor(가중치 적용 점수 계산)saveAll()배치 저장집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다.
mv_product_rank_weekly(ref_product_id, score, period_start, period_end, ranking_year_week)mv_product_rank_monthly(ref_product_id, score, period_start, period_end, ranking_year_month)Ranking API
GET /api/v1/rankings/daily)GET /api/v1/rankings/weekly)GET /api/v1/rankings/monthly)Key Changes
1. commerce-batch 모듈 신규 추가
Spring Batch 애플리케이션으로 랭킹 집계 Job 구현:
2. Chunk Oriented Processing 역할 분리
saveAll()배치 저장3. Ranking API 데이터 소스 분리
GET /api/v1/rankings/dailyGET /api/v1/rankings/weeklyGET /api/v1/rankings/monthly4. ProductMetrics 날짜별 집계 구조 개선
ProductMetricsId복합키 변경:(ref_product_id, metric_date)metric_date컬럼 추가 (YYYYMMDD 형식)5. Job 트리거 방식
@Scheduled로 자동 실행POST /api/v1/jobs/ranking으로 수동 실행스케줄 설정:
0 0 2 * * MON0 0 3 1 * *수동 API:
Review Points
1. Reader/Processor/Writer 역할 분리 구조
아래와 같이 Reader, Processor, Writer이 역할을 분리햇는데, 이 구조가 적절한지 리뷰 받아보고 싶습니다.
설계 배경:
ORDER BY score DESC로 랭킹 계산트레이드오프:
2. 주간/월간 랭킹 API의 기간 정책 표현
현재의 URL 설계 만으로는 API를 사용하는 입장에서 "월요일~일요일"이라는 비즈니스 정책을 나타내기 부족하다고 생각이 들었습니다.
현재:
GET /api/v1/rankings/weekly?date=260101date=260101전달 시, 해당 날짜가 속한 주의 월요일~일요일 랭킹을 조회