Skip to content

Conversation

@JeonJe
Copy link
Collaborator

@JeonJe JeonJe commented Jan 1, 2026

Round 10 - Spring Batch 기반 주간/월간 랭킹 시스템 구축

Summary

10주차 Spring-Batch와 MV로 주간/월간 기능을 구현하였습니다.

  • Spring Batch Job으로 product_metrics 일간 데이터를 집계하여 주간/월간 상품별 점수 저장
  • Materialized View 테이블(mv_product_rank_weekly, mv_product_rank_monthly)에 집계 결과 저장
  • 랭킹 API 확장: 일간(Redis ZSet), 주간/월간(MV 테이블) 데이터 소스 분리
  • Chunk Oriented Processing (Reader/Processor/Writer) 패턴으로 대량 데이터 배치 처리
항목 내용
핵심 변경 Spring Batch 기반 주간/월간 랭킹 집계 시스템
아키텍처 commerce-batch 모듈 신규 추가, MV 테이블 설계
주요 기술 Spring Batch, JpaPagingItemReader, Chunk Processing

Checklist

Spring Batch

  • Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다.

    • RankingAggregationJobConfig에서 period, baseDate 파라미터로 Job 실행
  • Chunk Oriented Processing 기반의 배치 처리를 구현했다.

    • Reader: JpaPagingItemReader (GROUP BY + SUM 집계 쿼리)
    • Processor: RankingItemProcessor (가중치 적용 점수 계산)
    • Writer: 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

  • API가 일간, 주간, 월간 랭킹을 제공하며 적절한 데이터를 기반으로 랭킹을 제공한다.
    • 일간: Redis ZSet 조회 (GET /api/v1/rankings/daily)
    • 주간: MV 테이블 조회 (GET /api/v1/rankings/weekly)
    • 월간: MV 테이블 조회 (GET /api/v1/rankings/monthly)

Key Changes

1. commerce-batch 모듈 신규 추가

Spring Batch 애플리케이션으로 랭킹 집계 Job 구현:

apps/commerce-batch/
├── job/ranking/
│   ├── RankingAggregationJobConfig.java  # Job/Step 설정
│   ├── RankingItemProcessor.java          # 가중치 점수 계산
│   ├── AggregatedProductScore.java        # 집계 DTO
│   └── DateRange.java                     # 기간 범위 record
├── domain/ranking/
│   ├── WeeklyProductRank.java             # 주간 MV 엔티티
│   └── MonthlyProductRank.java            # 월간 MV 엔티티
├── interfaces/
│   ├── BatchJobController.java            # 수동 실행 API
│   └── RankingScheduler.java              # @Scheduled 스케줄러
└── config/
    └── RankingBatchProperties.java        # 가중치/청크 설정

2. Chunk Oriented Processing 역할 분리

구분 역할
Reader GROUP BY + SUM (합계) + ORDER BY id
Processor 가중치 적용 점수 계산 (view0.1 + like0.3 + order*0.6)
Writer saveAll() 배치 저장

3. Ranking API 데이터 소스 분리

기간 데이터 소스 엔드포인트
일간 Redis ZSet GET /api/v1/rankings/daily
주간 MV 테이블 GET /api/v1/rankings/weekly
월간 MV 테이블 GET /api/v1/rankings/monthly

4. ProductMetrics 날짜별 집계 구조 개선

  • ProductMetricsId 복합키 변경: (ref_product_id, metric_date)
  • metric_date 컬럼 추가 (YYYYMMDD 형식)

5. Job 트리거 방식

방식 설명
스케줄러 @Scheduled로 자동 실행
수동 API POST /api/v1/jobs/ranking으로 수동 실행

스케줄 설정:

기간 크론 표현식 실행 시점
주간 0 0 2 * * MON 매주 월요일 02:00 (전주 집계)
월간 0 0 3 1 * * 매월 1일 03:00 (전월 집계)

수동 API:

POST /api/v1/jobs/ranking?period=WEEKLY&date=20260101
POST /api/v1/jobs/ranking?period=MONTHLY&date=20260101

Review Points

1. Reader/Processor/Writer 역할 분리 구조

아래와 같이 Reader, Processor, Writer이 역할을 분리햇는데, 이 구조가 적절한지 리뷰 받아보고 싶습니다.

구분 역할 설명
Reader GROUP BY + SUM (합계만) 데이터 조회/집계
Processor 가중치 점수 계산 비즈니스 로직 (view×0.1 + like×0.3 + order×0.6)
Writer 점수 저장만 rank_position 없이 score만 저장

설계 배경:

  • Processor 제약: 1:1 매핑만 가능 (1개 input → 1개 output). 여러 아이템을 모아서 정렬하거나 전체 순위를 부여할 수 없음
  • Chunk 병렬 처리 문제: chunkSize=100일 때 각 chunk가 독립적으로 Writer에 전달되어, chunk마다 rank 1~100이 중복 저장되는 문제 발생 → rank_position 저장 제거
  • 해결: rank_position은 "파생 데이터"로 판단. MV에는 score만 저장하고, API 조회 시 ORDER BY score DESC로 랭킹 계산

트레이드오프:

  • 장점: Chunk 병렬 처리 가능, Writer 로직 단순화, TOP 100 → 500 확장 시 코드 변경 불필요
  • 단점: 조회 시 정렬 비용 (TOP 100 규모에서 무시 가능)

2. 주간/월간 랭킹 API의 기간 정책 표현

현재의 URL 설계 만으로는 API를 사용하는 입장에서 "월요일~일요일"이라는 비즈니스 정책을 나타내기 부족하다고 생각이 들었습니다.

  • Swagger 등 API 문서나 사내 비즈니스 정책 약속을 통해 인지는 가능할 것 같은데
  • URL 설계 관점에서 이런 기간 정책을 더 명확히 표현할 수 있는 방법이 있을지 궁금합니다

현재: GET /api/v1/rankings/weekly?date=260101

  • date=260101 전달 시, 해당 날짜가 속한 주의 월요일~일요일 랭킹을 조회
  • 예: 2026년 1월 1일(목) → 2025년 12월 29일(월) ~ 2026년 1월 4일(일) 주간 랭킹 반환

hubtwork and others added 5 commits December 31, 2025 21:27
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에 주간/월간 조회 메서드 추가
@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

주간 및 월간 랭킹 조회 기능을 추가하고 새로운 배치 모듈(commerce-batch)을 도입하여 랭킹 데이터를 집계하는 시스템을 구축합니다. 메트릭 날짜 처리 및 MV 엔티티 매핑을 통해 시계열 랭킹 데이터를 관리합니다.

Changes

Cohort / File(s) 변경 사항
Daily/Weekly/Monthly 랭킹 API 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java
기존 daily 랭킹 설명 업데이트, 주간 및 월간 조회 엔드포인트 추가 (/weekly, /monthly)
RankingFacade 확장
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
getWeeklyRanking(), getMonthlyRanking() 메서드 추가 및 공통 로직을 buildResult() 헬퍼로 추출
RankingService 확장
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
getWeeklyTopN(), getMonthlyTopN() 메서드 추가, toRankingEntries()toYearWeek() 헬퍼 구현, 신규 저장소 의존성 추가
ProductRankView 인터페이스
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java
getRefProductId(), getScore() 접근자를 정의하는 새로운 마커 인터페이스
Weekly MV 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java
주간 랭킹 MV 엔티티, 복합키, 도메인/인프라 저장소 계층 구현
Monthly MV 엔티티 및 저장소
apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java
월간 랭킹 MV 엔티티, 복합키, 도메인/인프라 저장소 계층 구현
배치 애플리케이션 기초
apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java, apps/commerce-batch/src/main/resources/application.yml
새로운 commerce-batch 모듈 생성, Spring Batch 의존성 설정, 배치 애플리케이션 진입점 및 설정 파일 작성
배치 작업 조율 및 설정
apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java, apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java
배치 작업 실행 파사드, 더미 작업, 가중치/청크 크기 설정 속성 구성
배치 랭킹 집계 도메인
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java
제품 메트릭 엔티티, 복합키, 랭킹 기간 열거형, 주간/월간 랭킹 엔티티 정의
배치 아이템 처리 및 저장소
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java
집계된 점수 레코드, 날짜 범위, 랭킹 집계 작업 설정, 아이템 프로세서 및 저장소 구현
배치 리스너
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java, apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
청크/작업/단계 실행 모니터링 및 로깅 리스너 구현
배치 컨트롤러 및 테스트
apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java, apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java, apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java, apps/commerce-batch/src/test/java/com/loopers/job/ranking/...
배치 작업 REST 엔드포인트, 데모 작업 설정, 단위/E2E 테스트
Streamer 메트릭 날짜 처리
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java, apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
에포크 밀리초를 메트릭 날짜로 변환, 복합키 기반 메트릭 저장소 서명 업데이트
Streamer 이벤트 전략 업데이트
apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java, apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java, apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java, apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java
모든 메트릭 전략에 메트릭 날짜 인자 추가, MetricDateConverter 활용
인프라 저장소
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
제네릭 타입을 ProductMetricsId로 변경, SQL INSERT 쿼리에 메트릭 날짜 컬럼 추가
프로젝트 구성
README.md, settings.gradle.kts
README에 commerce-batch 모듈 추가, Gradle 설정에 모듈 포함

Sequence Diagrams

sequenceDiagram
    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>
Loading
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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement, batch-job, ranking-feature

Poem

🐰 배치 잡이 열심히 뛰어다니며,

주간, 월간 랭킹을 모아 정렬하네.

메트릭 날짜로 시간을 묶고,

가중치의 마법으로 점수를 계산해,

MV 테이블에 순위를 새기니.

데이터 스트림의 끝에서 만나는 깔끔한 완성! 🌟

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive 제목 '[volume-10] Collect, Stack, Zip'은 모호하고 구체적이지 않아 변경 사항의 주요 내용을 명확히 전달하지 못합니다. 제목을 더 구체적으로 변경하세요. 예: '[volume-10] Add weekly/monthly ranking APIs and batch job' 또는 '[volume-10] Implement ranking aggregation batch job with ranking APIs'
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 Spring Batch 기반 주간/월간 랭킹 시스템 구축의 목표, 주요 변경 사항, 체크리스트, 설계 검토 포인트를 포함하여 대체로 완전하게 작성되었습니다.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@JeonJe
Copy link
Collaborator Author

JeonJe commented Jan 1, 2026

@CodeRabbit 아래체크리스트 만족했는지 확인해주세요.

✅ Checklist

🧱 Spring Batch

  • Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.
  • 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다.

🧩 Ranking API

  • API 가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다.

@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Searching for Spring Batch Job configurations ==="
fd -e java | xargs grep -l "@EnableBatchProcessing\|@Configuration.*Batch\|JobBuilderFactory\|StepBuilderFactory" 2>/dev/null

echo -e "\n=== Searching for Batch Job definitions ==="
fd -e java | xargs grep -l "Job\|Step" | xargs grep -l "Reader\|Processor\|Writer\|Tasklet" 2>/dev/null

echo -e "\n=== Searching for ItemReader/ItemProcessor/ItemWriter implementations ==="
ast-grep --pattern 'class $_ implements ItemReader<$_> { $$$ }'
ast-grep --pattern 'class $_ implements ItemProcessor<$_, $_> { $$$ }'
ast-grep --pattern 'class $_ implements ItemWriter<$_> { $$$ }'

echo -e "\n=== Searching for Tasklet implementations ==="
ast-grep --pattern 'class $_ implements Tasklet { $$$ }'

echo -e "\n=== Searching for Ranking API controllers ==="
fd -e java | xargs grep -l "Ranking" | xargs grep -l "@RestController\|@Controller" 2>/dev/null

echo -e "\n=== Searching for daily/weekly/monthly ranking endpoints ==="
rg -n "daily|weekly|monthly" --type java -A 3 -B 3

echo -e "\n=== Searching for Materialized View or aggregate tables ==="
rg -n "materialized|aggregate|view" --type java -i -A 2 -B 2

echo -e "\n=== List all Java files in the repository ==="
fd -e java -t f

Length of output: 131928


🏁 Script executed:

#!/bin/bash

echo "=== RankingAggregationJobConfig.java ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java

echo -e "\n=== RankingItemProcessor.java ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java

echo -e "\n=== RankingController.java ==="
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java

echo -e "\n=== RankingService.java ==="
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java

echo -e "\n=== WeeklyProductRank.java ==="
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java

echo -e "\n=== MonthlyProductRank.java ==="
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java

Length of output: 18034


@JeonJe 체크리스트 항목들을 확인했습니다! 모든 항목이 만족되었습니다. 👍

✅ Spring Batch

✅ Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다

  • RankingAggregationJobConfig에서 rankingAggregationJob() 생성
  • Job Parameters 활용:
    • period: "weekly" 또는 "monthly"
    • baseDate: "yyyyMMdd" 형식
  • @JobScope, @StepScope를 통해 파라미터 바인딩 구현

✅ Chunk Oriented Processing 기반의 배치 처리를 구현했다

Reader: JpaPagingItemReader<AggregatedProductScore>

  • ProductMetrics 테이블에서 JPQL로 집계 (SUM)
  • 가중치 기반 정렬로 읽기

Processor: RankingItemProcessor

  • 가중치 기반 점수 계산: view*0.1 + like*0.3 + order*0.6
  • period에 따라 WeeklyProductRank 또는 MonthlyProductRank 엔티티로 변환

Writer: rankingWriter()

  • period 파라미터에 따라 분기
  • weeklyRepository.saveAll() 또는 monthlyRepository.saveAll() 호출

✅ Materialized View의 구조를 설계하고 올바르게 적재했다

주간 MV: mv_product_rank_weekly

  • ref_product_id, ranking_year_week, score, period_start, period_end, updated_at

월간 MV: mv_product_rank_monthly

  • ref_product_id, ranking_year_month, score, period_start, period_end, updated_at

적재 흐름: ProductMetrics → 집계(Reader) → 점수계산(Processor) → MV 저장(Writer)

✅ Ranking API

✅ API가 일간, 주간, 월간 랭킹을 제공하며 적절한 데이터를 기반으로 제공한다

엔드포인트:

  • /api/v1/rankings/dailyRankingService.getTopN()Redis 조회
  • /api/v1/rankings/weeklyRankingService.getWeeklyTopN()mv_product_rank_weekly 조회
  • /api/v1/rankings/monthlyRankingService.getMonthlyTopN()mv_product_rank_monthly 조회

각 랭킹 형태에 따라 최적의 데이터 소스를 사용하고 있습니다! 🎯

      (\_(\ 
      (=' :') ✨ 완벽합니다!
      (,(')(') 

Copy link

@coderabbitai coderabbitai bot left a 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: 날짜 값에 대한 유효성 검증을 추가하는 것을 권장합니다.

현재 구현은 다음과 같은 잠재적 문제가 있습니다:

  • startDateendDate보다 큰 값이 허용됨 (논리적 오류)
  • 유효하지 않은 날짜 값(예: 20260132)이 허용됨

대안으로 다음을 고려해보세요:

  1. 캐노니컬 생성자에서 유효성 검증 추가
  2. 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 java
apps/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: 배치 설정 값에 대한 유효성 검증 고려

chunkSizelimit이 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.java
  • apps/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 = trueflushAutomatically = 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.KOREA WeekFields를 사용하고 있어, 포맷 변경 시 불일치 위험이 있습니다.

공통 유틸리티 클래스로 추출하여 일관성을 유지하는 것을 권장합니다.

🔎 유틸리티 클래스 추출 예시
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::castMonthlyProductRank.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

📥 Commits

Reviewing files that changed from the base of the PR and between f992dc0 and 5d13db0.

📒 Files selected for processing (60)
  • README.md
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankView.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRank.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/WeeklyProductRankRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java
  • apps/commerce-batch/build.gradle.kts
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/application/BatchJobFacade.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/config/NoOpJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/config/RankingBatchProperties.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/ProductRankEntity.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/RankingPeriod.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/interfaces/BatchJobController.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingAggregationJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductMetricsTestRepository.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingJobTestConfig.java
  • apps/commerce-batch/src/test/resources/sql/cleanup.sql
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductSoldStrategy.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductViewedStrategy.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/MetricDateConverter.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java
  • settings.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.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyProductRankJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyProductRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/WeeklyProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/mv/MonthlyProductRankRepositoryImpl.java
  • apps/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.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/mv/MonthlyProductRankId.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRankId.java
  • apps/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.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java
  • apps/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.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/strategy/ProductMetricsIntegrationTest.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductUnlikedStrategy.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/strategy/ProductLikedStrategy.java
  • apps/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.java
  • apps/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!

WeeklyProductRankMonthlyProductRank의 공통 마커 인터페이스로 적절합니다. 배치 처리 시 타입 안전성을 제공합니다.

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!

MetricDateConverterRankingRedisProperties 의존성 추가가 적절하게 이루어졌습니다. 타임존 기반 메트릭 날짜 계산을 위한 올바른 구조입니다.

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 쿼리 메서드가 올바르게 정의되어 있습니다. PageableList 반환 타입의 조합은 전체 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-apicommerce-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: 새로운 주간/월간 랭킹 조회 메서드가 적절하게 구현되었습니다.

getWeeklyTopNgetMonthlyTopN 메서드가 각각의 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를 사용한 주간/월간 경계 계산이 올바르게 구현되었고, yyyyMMdd Integer 형식이 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의 모든 호출 코드가 새 시그니처에 맞게 정확하게 업데이트되었습니다.

Comment on lines 29 to 35
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();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

읽기 전용 엔티티에서 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.

Suggested change
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.

Comment on lines +15 to +29
// 인증/권한 체크 필요
@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));
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

인증/권한 체크 미구현 상태

주석에 "인증/권한 체크 필요"라고 명시되어 있으나 구현되지 않았습니다. 배치 작업 트리거 엔드포인트는 보안에 민감하므로, 운영 환경 배포 전에 반드시 인증/권한 체크를 구현해야 합니다.

추가로, 동일한 기간에 대해 중복 실행 방지 로직도 고려해 보세요.

인증/권한 체크 구현을 도와드릴까요? 또는 별도 이슈로 추적할 수 있도록 생성해 드릴까요?

🤖 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.

Comment on lines +10 to +12
public double calculateScore(double viewWeight, double likeWeight, double orderWeight) {
return (totalViewCount * viewWeight) + (totalLikeCount * likeWeight) + (totalSalesCount * orderWeight);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +14 to +20
@AfterChunk
void afterChunk(ChunkContext chunkContext) {
log.info(
"청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " +
"writeCount: ${chunkContext.stepContext.stepExecution.writeCount}"
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

잘못된 문자열 보간 구문으로 로그가 올바르게 출력되지 않음

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.

Suggested change
@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.

Comment on lines +19 to +23
@BeforeJob
void beforeJob(JobExecution jobExecution) {
log.info("Job '${jobExecution.jobInstance.jobName}' 시작");
jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
@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.

Comment on lines +31 to +38
log.info(
"""
[에러 발생]
jobName: {}
exceptions:
{}
""".trim(), jobName, exceptions
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

에러 로깅 레벨을 수정하세요.

에러 정보를 로깅할 때 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.

Suggested change
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.

Comment on lines 26 to 30
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "*.java" -type f | head -20

Repository: 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 -50

Repository: 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 -100

Repository: 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 -80

Repository: 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 2

Repository: 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 -85

Repository: 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 2

Repository: 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 -100

Repository: 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 -30

Repository: 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.

Comment on lines +13 to +22
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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() 변경
@JeonJe
Copy link
Collaborator Author

JeonJe commented Jan 1, 2026

@CodeRabbit
리뷰 피드백 반영했습니다. 확인해주세요

  • 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() 변경

@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== 1. ProductMetrics.of() updatedAt parameter (batch) ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java | grep -A 10 "public static ProductMetrics of"

echo -e "\n=== 2. AggregatedProductScore compact constructor ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/AggregatedProductScore.java

echo -e "\n=== 3. DateRange validation ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/DateRange.java

echo -e "\n=== 4. RankingItemProcessor null checks and beforeStep ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java | grep -A 30 "@BeforeStep"

echo -e "\n=== 5. WeeklyProductRankId serialVersionUID ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRankId.java | grep -A 2 "serialVersionUID"

echo -e "\n=== 6a. Factory method null validation (batch WeeklyProductRank) ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java | grep -A 15 "public static WeeklyProductRank of"

echo -e "\n=== 6b. Factory method null validation (batch MonthlyProductRank) ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java | grep -A 15 "public static MonthlyProductRank of"

Length of output: 4478


🏁 Script executed:

#!/bin/bash

echo "=== 6c. Factory method null validation (streamer ProductMetrics) ==="
cat apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java | grep -A 15 "public static ProductMetrics createWithLike"

echo -e "\n=== 6d. ProductMetricsId.of() null validation ==="
cat apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java | grep -A 10 "public static ProductMetricsId of"

echo -e "\n=== 7. Pagination parameter validation (RankingService) ==="
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java | grep -B 5 -A 10 "getWeeklyTopN\|getMonthlyTopN" | head -40

echo -e "\n=== 8. Test improvements (fixed time) ==="
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java | grep -A 5 "LocalDateTime\|Instant\|@Test" | head -50

echo -e "\n=== 9. cleanup.sql TRUNCATE ==="
cat apps/commerce-batch/src/test/resources/sql/cleanup.sql

echo -e "\n=== 10. RankingService Locale change ==="
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java | grep -A 2 "Locale"

Length of output: 4411


🏁 Script executed:

#!/bin/bash

echo "=== 6a. WeeklyProductRank constructor validation ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyProductRank.java

echo -e "\n=== 6b. MonthlyProductRank constructor validation ==="
cat apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyProductRank.java

echo -e "\n=== 6d. ProductMetricsId constructor validation ==="
cat apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsId.java

Length of output: 4926


🏁 Script executed:

#!/bin/bash

echo "=== 7a. RankingFacade pagination validation ==="
cat apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | grep -B 5 -A 15 "getWeeklyRanking\|getMonthlyRanking"

echo -e "\n=== 7b. RankingController parameter annotations ==="
cat apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingController.java | grep -B 3 -A 10 "getWeeklyRanking\|getMonthlyRanking"

echo -e "\n=== 8. Test score assertion ==="
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java | grep -A 20 "점수 계산이 올바르게 동작한다"

Length of output: 2484


🏁 Script executed:

#!/bin/bash

echo "=== 8. Test with score calculation verification ==="
cat apps/commerce-batch/src/test/java/com/loopers/job/ranking/RankingAggregationJobE2ETest.java | grep -B 5 -A 25 "shouldCalculateScore"

echo -e "\n=== 6. Check if batch entity factory methods need validation ==="
echo "Note: Batch entities (WeeklyProductRank, MonthlyProductRank) are created only by the batch processor"
echo "which already validates inputs before calling the factory methods."
cat apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingItemProcessor.java | grep -B 3 -A 8 "WeeklyProductRank.of\|MonthlyProductRank.of"

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 개선 ✅

  • Job 파라미터 null 체크 추가
  • batchStartTime@BeforeStep에서 한 번만 설정하여 배치 내 시간 통일
@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):
배치 엔티티는 RankingItemProcessor에서만 생성되며, 프로세서가 이미 입력값을 검증하므로 팩토리 메서드에 중복 검증을 추가하지 않은 것이 적절합니다.

7. 페이지네이션 경계 조건 검증 ✅

@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size

8. 테스트 개선 ✅

  • 고정 시간: AtomicLong RUN_ID_COUNTER 사용
  • 점수 검증 강화:
assertThat(ranks.get(0).getScore()).isEqualTo(16.0);
// view(100)*0.1 + like(10)*0.3 + sales(5)*0.6 = 16.0

9. 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());

모든 피드백이 완벽하게 반영되었습니다! 코드 품질과 안정성이 크게 향상되었습니다. 👏

      (\_(\ 
      (=' :')  Perfect!
      (,(')(')  All feedback addressed!

🧠 Learnings used
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 + 재시도)을 사용한다.

If 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: 점수 계산
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants