Skip to content

Conversation

@rnqhstmd
Copy link
Collaborator

@rnqhstmd rnqhstmd commented Jan 2, 2026

📌 Summary

  • Spring Batch로 product_metrics 테이블 조회 후 주간/월간 TOP 100 랭킹 생성
  • Materialized View 테이블 (mv_product_rank_weekly, mv_product_rank_monthly) 설계 및 적재
  • 랭킹 API 확장: RankingPeriod enum으로 일간(Redis)/주간(DB)/월간(DB) 데이터 소스 분리

💬 Review Points

  • MV 테이블 엔티티가 Api - 읽기용 Batch - 쓰기용 이런식으로 각각에 존재하도록 구현했습니다. 이런 경우 스키마 변경 시 두 엔티티 모두 수정이 필요하고, 관리 포인트가 오히려 증가될 것 같은데, Api 모듈에서 DTO Projection으로 조회만하고 Batch 모듈에만 엔티티를 소유하는 방식이 좋을까요? 아니면 현재 방식이 좋을까요?

✅ Checklist

🧱 Spring Batch

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

🧩 Ranking API

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

Summary by CodeRabbit

릴리스 노트

  • New Features
    • 상품 순위 조회에 기간 선택 기능 추가 (일간, 주간, 월간)
    • 순위 조회 및 Top N 조회 API에 기간 파라미터 추가 (기본값: 일간)
    • 주간 및 월간 순위 자동 생성 배치 작업 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@rnqhstmd rnqhstmd self-assigned this Jan 2, 2026
@rnqhstmd rnqhstmd added the enhancement New feature or request label Jan 2, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 2, 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

다중 주기(일별, 주별, 월별) 랭킹 조회 기능을 지원하기 위해 API, 애플리케이션 서비스, 도메인 엔티티, 저장소 및 배치 작업을 확장합니다. RankingPeriod 열거형, WeeklyRanking/MonthlyRanking JPA 엔티티, 기간별 쿼리 메서드, 주간/월간 랭킹 배치 작업 및 API 엔드포인트를 추가합니다.

Changes

결합군 / 파일 변경 요약
API 스펙 및 컨트롤러
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java, RankingV1Controller.java, RankingV1Dto.java
getRankings 및 getTopN 엔드포인트에 기간 파라미터(daily/weekly/monthly) 추가; RankingPeriod enum 지원; 응답 DTO에 기간 필드 포함
애플리케이션 레이어
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java, RankingFacade.java, RankingPageInfo.java
RankingCommand에 period 필드 추가 및 팩토리 메서드 확장; RankingFacade에 기간별 getTopN/getProductRank 메서드 추가; RankingPageInfo에 period 필드 및 오버로드된 팩토리 메서드 추가
도메인 엔티티 (API 서버)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java, WeeklyRanking.java, MonthlyRanking.java
RankingPeriod enum (DAILY, WEEKLY, MONTHLY) 정의; WeeklyRanking, MonthlyRanking JPA 엔티티 추가 (테이블: mv_product_rank_weekly, mv_product_rank_monthly)
저장소 및 변환기
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/\\*JpaRepository.java, RankingRepositoryImpl.java, YearMonthAttributeConverter.java
RankingRepository 인터페이스에 주간/월간 조회 메서드 정의; WeeklyRankingJpaRepository, MonthlyRankingJpaRepository 구현; RankingRepositoryImpl 통합; YearMonth ↔ String 변환기 추가
RankingService 확장
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
Redis 기반 일별 랭킹과 저장소 기반 주간/월간 랭킹 지원; getWeeklyRankingPage, getMonthlyRankingPage 및 관련 메서드 추가
배치 애플리케이션 설정
apps/commerce-batch/build.gradle.kts, CommerceBatchApplication.java, application.yml
새 commerce-batch 모듈 추가; 배치 애플리케이션 진입점 및 기본 설정
배치 도메인 및 저장소
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/\\*, apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/\\*
ProductMetrics, ProductMetricsId, ProductMetricsRepository 추가; WeeklyRanking, MonthlyRanking 배치 엔티티 및 저장소 추가
배치 작업 (주간/월간 랭킹)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java, MonthlyRankingJobConfig.java, reader/\\*Reader.java, processor/\\*Processor.java, writer/\\*Writer.java
주간/월간 랭킹 계산 배치 작업 정의; 메트릭 집계 → 점수 계산 → 저장 파이프라인 구현
배치 유틸리티
apps/commerce-batch/src/main/java/com/loopers/batch/domain/RankingWeightReader.java, listener/\\*Listener.java
랭킹 가중치 읽기 및 점수 계산; 배치 작업/단계/청크 수준 모니터링 리스너
배치 인터페이스
apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java
주간/월간 랭킹 배치 작업 실행 엔드포인트 (POST /api/v1/batch/weekly-ranking, /monthly-ranking)
배치 인프라
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/\\*, apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/\\*
ProductMetricsJpaRepository, ProductMetricsRepositoryImpl; WeeklyRankingJpaRepository, MonthlyRankingJpaRepository, RankingRepositoryImpl 추가
DTO 및 변환기
apps/commerce-batch/src/main/java/com/loopers/dto/ProductMetricsSummary.java, RankedProduct.java, YearMonthAttributeConverter.java
메트릭 집계 DTO 및 순위 상품 레코드 추가; YearMonth 변환기 (배치 모듈용)
배치 테스트
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java, DemoJobE2ETest.java
배치 애플리케이션 컨텍스트 로드 테스트; 데모 작업 E2E 테스트
의존성 업데이트
apps/commerce-streamer/build.gradle.kts, settings.gradle.kts
commerce-batch 모듈 등록; SpringDoc OpenAPI UI 의존성 추가

Sequence Diagrams

sequenceDiagram
    actor Client
    participant API as RankingV1<br/>Controller
    participant Facade as RankingFacade
    participant Service as RankingService
    participant Redis as Redis
    participant Repo as RankingRepository
    participant DB as Database

    Client->>API: GET /rankings?period=weekly
    API->>Facade: getRankingPage(date, WEEKLY, page, size)
    
    alt Weekly Period
        Facade->>Service: getWeeklyRankingPage(date, page, size)
        Service->>Repo: findWeeklyByDateOrderByRank(weekStart, weekEnd, limit, offset)
        Repo->>DB: Query WeeklyRanking
        DB-->>Repo: Rankings
        Repo-->>Service: List<WeeklyRanking>
        Service-->>Facade: List<RankingEntry>
    else Daily Period
        Facade->>Service: getDailyRankingPage(date, page, size)
        Service->>Redis: ZREVRANGE ranking:all:yyyyMMdd
        Redis-->>Service: Ranked entries
        Service-->>Facade: List<RankingEntry>
    end
    
    Facade->>Facade: Build RankingPageInfo with period
    Facade-->>API: RankingPageInfo
    API-->>Client: RankingPageResponse with period field
Loading
sequenceDiagram
    participant Job as Spring Batch<br/>Scheduler
    participant Reader as WeeklyRanking<br/>Reader
    participant Processor as WeeklyRanking<br/>Processor
    participant Writer as WeeklyRanking<br/>Writer
    participant Repo as Ranking<br/>Repository
    participant DB as Database

    Job->>Reader: Read metrics for week
    Reader->>Repo: findAllByDateRange(startDate, endDate)
    Repo->>DB: Query ProductMetrics
    DB-->>Repo: Metrics summary
    Repo-->>Reader: ProductMetricsSummary list
    Reader->>Reader: Aggregate & calculate scores
    
    loop For each RankedProduct
        Reader->>Processor: RankedProduct
        Processor->>Processor: Assign rank (1, 2, 3...)
        Processor-->>Writer: WeeklyRanking
    end
    
    Writer->>Repo: deleteByWeekStartAndWeekEnd()
    Repo->>DB: DELETE old rankings
    Writer->>Repo: saveAll(rankings)
    Repo->>DB: INSERT new rankings
    DB-->>Repo: Success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • [volume-9] Product Ranking with Redis  #223: RankingFacade, RankingV1Controller, RankingInfo 및 RankingService 계층의 직접적인 코드 수준 변경을 공유하며 동일한 랭킹 서브시스템 수정
  • [volume - 9] What is Popularity? #210: 핵심 Redis 기반 랭킹 및 컨슈머 로직을 구현하면서, 본 PR은 기간별(일별/주별/월별) API, 서비스, JPA 엔티티 및 배치 작업으로 확장
  • [volume-9] Product Ranking with Redis #221: 동일한 랭킹 도메인/API 클래스(RankingCommand, RankingFacade, RankingPageInfo, DTO, 컨트롤러, RankingService)를 수정하여 기간별 동작 추가

Poem

🐰 주간, 월간, 그리고 날마다
랭킹의 시간을 나누네
배치가 조용히 밤새 계산하고
API는 모든 주기를 품어내네
점수와 순위, 기간의 춤
🥕 홉 홉 홉, 우리의 랭킹!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 2 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.54% 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'은 주간/월간 랭킹 수집, 적재, 통합 작업의 핵심을 암시하지만 매우 추상적이고 기술적 세부사항이 부족합니다. 변경사항을 더 명확히 나타내는 제목으로 수정하기를 권장합니다. 예: '[Ranking] Add Weekly/Monthly Ranking with Spring Batch' 또는 '[Feature] Implement Period-based Ranking with Materialized Views'
Description check ❓ Inconclusive PR 설명이 요약, 리뷰 포인트, 체크리스트를 포함하고 있으나, 리뷰 포인트 섹션에서 구체적인 설계 결정 및 예외 상황 검증에 대한 세부 정보가 부족합니다. 리뷰 포인트 섹션에 다음을 추가해주세요: (1) 주간/월간 MV 적재 검증 방식 (2) 배치 실패 시 재처리 전략 (3) 성능 및 동시성 테스트 내용 (4) API 하위호환성 확인 사항

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

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: 10

🧹 Nitpick comments (37)
apps/commerce-batch/src/main/resources/application.yml (2)

20-23: 헬스 체크 비활성화가 관찰성에 영향을 줄 수 있습니다.

배치 애플리케이션이라도 운영 모니터링을 위해 기본 헬스 인디케이터를 활성화하는 것을 고려해보세요. 작업 실행 상태, 데이터베이스 연결 등의 헬스 체크는 운영 환경에서 유용합니다.


34-50: 빈 프로파일 섹션을 정리하는 것을 고려하세요.

dev, qa, prd 프로파일 섹션이 현재 비어있습니다. 향후 프로파일별 설정이 필요할 때 추가하는 것으로 보일러플레이트를 줄일 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsId.java (1)

13-24: 복합 키 인스턴스 생성을 위한 생성자 추가를 권장합니다.

현재 protected 기본 생성자만 있어 ProductMetricsId 인스턴스를 생성하기 어렵습니다. 테스트 코드나 엔티티 수동 생성 시 불편할 수 있습니다.

🔎 전체 인자 생성자 추가 제안
 @Embeddable
 @Getter
 @EqualsAndHashCode
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
 public class ProductMetricsId implements Serializable {
apps/commerce-api/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java (1)

8-20: YearMonth 변환기가 올바르게 구현되었습니다.

ISO-8601 형식(yyyy-MM)을 사용한 표준 구현이며, null 처리도 적절합니다. autoApply = true 설정으로 모든 YearMonth 필드에 자동 적용됩니다.

선택적으로 저장 형식을 클래스 JavaDoc에 명시하면 유지보수에 도움이 될 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/dto/ProductMetricsSummary.java (1)

8-17: DTO 구현이 올바르게 작동합니다.

JPQL 프로젝션용 DTO로 잘 설계되었습니다.

선택적으로 필드를 final로 선언하면 불변성을 보장하여 더 안전한 DTO가 됩니다.

🔎 불변 DTO로 개선하는 제안
 @Getter
 @AllArgsConstructor
 public class ProductMetricsSummary {
 
-    private Long productId;
-    private LocalDate date;
-    private Long likeCount;
-    private Long stockCount;
-    private Long viewCount;
+    private final Long productId;
+    private final LocalDate date;
+    private final Long likeCount;
+    private final Long stockCount;
+    private final Long viewCount;
 }
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java (2)

31-37: 명시적인 @column 매핑 추가를 고려하세요.

productId, score, weekStart, weekEnd 필드에 @column 어노테이션이 없습니다. Materialized view의 경우 스키마 변경 시 명확성을 위해 명시적인 컬럼 매핑을 권장합니다.

🔎 제안하는 수정
-    private Long productId;
+    @Column(name = "product_id", nullable = false)
+    private Long productId;

-    private double score;
+    @Column(nullable = false)
+    private double score;

-    private LocalDate weekStart;
+    @Column(name = "week_start", nullable = false)
+    private LocalDate weekStart;

-    private LocalDate weekEnd;
+    @Column(name = "week_end", nullable = false)
+    private LocalDate weekEnd;

39-49: 팩토리 메서드에 유효성 검증 추가를 권장합니다.

파라미터 검증(null 체크, weekStart가 weekEnd보다 이전인지, score가 음수가 아닌지 등)이 없어 잘못된 데이터가 생성될 수 있습니다.

🔎 제안하는 수정
 public static WeeklyRanking create(int rank, Long productId, double score, LocalDate weekStart, LocalDate weekEnd) {
+    if (productId == null || weekStart == null || weekEnd == null) {
+        throw new IllegalArgumentException("productId, weekStart, weekEnd cannot be null");
+    }
+    if (weekStart.isAfter(weekEnd)) {
+        throw new IllegalArgumentException("weekStart must be before or equal to weekEnd");
+    }
+    if (score < 0) {
+        throw new IllegalArgumentException("score cannot be negative");
+    }
+
     WeeklyRanking weeklyRanking = new WeeklyRanking();
     
     weeklyRanking.rank = rank;
     weeklyRanking.productId = productId;
     weeklyRanking.score = score;
     weeklyRanking.weekStart = weekStart;
     weeklyRanking.weekEnd = weekEnd;
     
     return weeklyRanking;
 }
apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java (1)

16-19: 파싱 예외 처리를 추가하는 것을 권장합니다.

YearMonth.parse()가 DB에 잘못된 형식의 데이터가 있을 경우 DateTimeParseException을 던질 수 있습니다. 더 명확한 에러 메시지와 함께 예외 처리를 추가하는 것이 좋습니다.

🔎 제안하는 수정
 @Override
 public YearMonth convertToEntityAttribute(String dbData) {
-    return dbData != null ? YearMonth.parse(dbData) : null;
+    if (dbData == null) {
+        return null;
+    }
+    try {
+        return YearMonth.parse(dbData);
+    } catch (DateTimeParseException e) {
+        throw new IllegalArgumentException("Invalid YearMonth format in database: " + dbData, e);
+    }
 }
apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRanking.java (1)

19-28: 명시적인 컬럼 매핑 추가를 고려하세요.

API 모듈은 읽기 전용이지만, 명시적인 @column 어노테이션을 추가하면 배치 모듈과의 스키마 일관성 확인이 용이합니다. 특히 엔티티 중복 문제를 해결하기 전까지는 더욱 중요합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/RankingWeightReader.java (2)

42-44: 파라미터 순서를 계산 순서와 일치시키세요.

메서드 시그니처의 파라미터 순서 (totalLikes, totalSales, totalViews)가 계산식에서 사용되는 순서 (totalViews, totalLikes, totalSales)와 다릅니다. 가독성과 유지보수를 위해 일관된 순서를 사용하세요.

🔎 제안하는 수정
 /**
  * 총점 계산
  */
-public double calculateTotalScore(int totalLikes, int totalSales, int totalViews) {
+public double calculateTotalScore(int totalViews, int totalLikes, int totalSales) {
     return (totalViews * getViewWeight()) + (totalLikes * getLikeWeight()) + (totalSales * getOrderWeight());
 }

호출하는 곳도 함께 수정해야 합니다.


46-56: 파싱 예외를 더 명확하게 처리하는 것을 고려하세요.

Double.parseDouble()에서 발생하는 NumberFormatException과 Redis 조회 실패를 구분하면 디버깅이 더 쉬워집니다.

🔎 제안하는 수정
 private double getWeight(String field, double defaultValue) {
     try {
         Object value = redisTemplate.opsForHash().get(WEIGHT_KEY, field);
         if (value != null) {
-            return Double.parseDouble(value.toString());
+            try {
+                return Double.parseDouble(value.toString());
+            } catch (NumberFormatException e) {
+                log.warn("가중치 파싱 실패, 기본값 사용: field={}, value={}, default={}", field, value, defaultValue);
+                return defaultValue;
+            }
         }
     } catch (Exception e) {
         log.warn("가중치 조회 실패, 기본값 사용: field={}, default={}", field, defaultValue, e);
     }
     return defaultValue;
 }
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java (1)

72-87: 집계 로직의 가독성을 개선할 수 있습니다.

double 배열을 사용한 집계는 동작하지만, 명명된 필드를 가진 중간 DTO를 사용하면 코드 가독성이 향상됩니다. 예를 들어 AggregatedMetrics(likeCount, stockCount, viewCount) 같은 레코드를 사용하면 sums[0], sums[1], sums[2]보다 의미가 명확합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/WeeklyRankingProcessor.java (1)

29-29: 단일 스레드 환경에서 AtomicInteger는 불필요합니다.

Spring Batch의 ItemProcessor는 기본적으로 단일 스레드에서 실행되므로, AtomicInteger 대신 일반 int 변수로도 충분합니다. 다만 현재 구현도 동작하므로 우선순위가 낮습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRanking.java (3)

32-32: productId 필드에 제약 조건을 명시하세요.

productId는 unique constraint의 일부이며 비즈니스 로직상 필수 값으로 보입니다. @Column(nullable = false)을 추가하여 데이터베이스 수준에서 제약을 강제하는 것을 권장합니다.

🔎 제안하는 수정
+    @Column(nullable = false)
     private Long productId;

40-49: 팩토리 메서드에 파라미터 검증을 추가하는 것을 고려하세요.

create 메서드가 null 파라미터나 유효하지 않은 값(예: 음수 rank)을 검증하지 않습니다. 데이터 무결성을 위해 검증 로직을 추가하는 것을 권장합니다.

🔎 제안하는 수정
 public static MonthlyRanking create(int rank, Long productId, double score, YearMonth yearMonth) {
+    if (productId == null) {
+        throw new IllegalArgumentException("productId는 필수입니다");
+    }
+    if (yearMonth == null) {
+        throw new IllegalArgumentException("yearMonth는 필수입니다");
+    }
+    if (rank < 0) {
+        throw new IllegalArgumentException("rank는 0 이상이어야 합니다");
+    }
+    
     MonthlyRanking monthlyRanking = new MonthlyRanking();
     
     monthlyRanking.rank = rank;
     monthlyRanking.productId = productId;
     monthlyRanking.score = score;
     monthlyRanking.monthPeriod = yearMonth;
     
     return monthlyRanking;
 }

10-23: JPA 엔티티에 equals/hashCode 구현을 고려하세요.

JPA 엔티티의 모범 사례로, 특히 컬렉션에서 사용되거나 비교가 필요한 경우 비즈니스 키(productId, monthPeriod)를 기반으로 equals/hashCode를 구현하는 것이 좋습니다.

apps/commerce-batch/src/test/java/com/loopers/job/step/DemoJobE2ETest.java (3)

4-4: 사용하지 않는 코드를 제거하세요.

@RequiredArgsConstructor 어노테이션은 생성자 주입할 필드가 없어 불필요합니다. 또한 빈 beforeEach() 메서드도 사용되지 않으므로 제거하는 것이 좋습니다.

🔎 제안하는 수정
 package com.loopers.job.step;
 
 import com.loopers.batch.job.demo.DemoJobConfig;
-import lombok.RequiredArgsConstructor;
 import org.junit.jupiter.api.BeforeEach;
-    @BeforeEach
-    void beforeEach() {
-
-    }
-
     @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.")

Also applies to: 37-40


42-56: 테스트 메서드 이름이 실제 테스트 내용과 일치하지 않습니다.

메서드 이름 shouldNotSaveCategories_whenApiError는 카테고리 저장 실패와 API 에러를 암시하지만, 실제로는 requestDate 파라미터가 누락되었을 때의 실패를 테스트합니다. 더 정확한 이름으로 변경하는 것을 권장합니다 (예: shouldFail_whenRequestDateParameterIsMissing).


28-29: 주석을 간결하게 정리할 수 있습니다.

주석이 유용하지만 다소 장황합니다. 더 간결하게 표현할 수 있습니다.

🔎 제안하는 수정
-    // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음.
-    // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함.
+    // Note: @SpringBatchTest가 scope 기반으로 주입하므로 IDE 경고는 무시 가능
     @Autowired
     private JobLauncherTestUtils jobLauncherTestUtils;
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)

27-29: System.out.println 대신 로거를 사용하세요.

System.out.println은 프로덕션 환경에서 로그 레벨 제어, 로그 수집 등이 불가능합니다. SLF4J 로거를 사용하는 것을 권장합니다.

🔎 제안하는 수정
 package com.loopers.batch.job.demo.step;
 
 import com.loopers.batch.job.demo.DemoJobConfig;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.batch.core.StepContribution;
+@Slf4j
 @StepScope
 @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME)
     @Override
     public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
         if (requestDate == null) {
             throw new RuntimeException("requestDate is null");
         }
-        System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")");
+        log.info("Demo Tasklet 실행 (실행 일자 : {})", requestDate);
         Thread.sleep(1000);
-        System.out.println("Demo Tasklet 작업 완료");
+        log.info("Demo Tasklet 작업 완료");
         return RepeatStatus.FINISHED;
     }

24-26: 더 구체적인 예외 타입을 사용하고 로깅을 추가하세요.

제네릭한 RuntimeException 대신 IllegalArgumentException 또는 커스텀 예외를 사용하고, 예외 발생 전에 로그를 남기는 것을 권장합니다.

🔎 제안하는 수정
     if (requestDate == null) {
-        throw new RuntimeException("requestDate is null");
+        log.error("필수 파라미터 requestDate가 누락되었습니다");
+        throw new IllegalArgumentException("requestDate는 필수 파라미터입니다");
     }
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)

23-43: 에러 모니터링 로직이 잘 구현되어 있습니다.

예외 메시지 수집 및 로깅 처리가 적절합니다. Line 39의 주석에서 언급된 Slack 등 외부 채널로의 알림 전송 기능을 구현할 수 있도록 도와드릴까요?

apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java (1)

12-12: 테이블 인덱스 정의를 추가하는 것을 권장합니다.

monthPeriodrank 필드에 대한 쿼리 성능 향상을 위해 인덱스를 정의하면 좋겠습니다. 특히 기간별 랭킹 조회 시 성능에 큰 영향을 줄 수 있습니다.

🔎 인덱스 추가 제안
-@Table(name = "mv_product_rank_monthly")
+@Table(name = "mv_product_rank_monthly",
+    indexes = {
+        @Index(name = "idx_monthly_period_rank", columnList = "month_period, monthly_rank"),
+        @Index(name = "idx_monthly_product", columnList = "product_id, month_period")
+    }
+)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.java (2)

48-49: 불필요한 캐스팅과 스트림 연산을 제거할 수 있습니다.

chunk.getItems()는 이미 List<? extends WeeklyRanking> 타입이므로, 스트림 변환과 캐스팅 없이 직접 전달할 수 있습니다.

🔎 간소화 제안
-        log.info("주간 랭킹 저장: count={}", chunk.size());
-        weeklyRankingRepository.saveAll(chunk.getItems().stream().map(item -> (WeeklyRanking) item).toList());
+        log.info("주간 랭킹 저장: count={}", chunk.size());
+        weeklyRankingRepository.saveAll(chunk.getItems());

52-64: 기본값 대신 필수 파라미터 검증을 고려해주세요.

현재 startDateendDate가 없을 때 현재 주의 월요일/일요일로 기본값을 사용하고 있습니다. 배치 작업의 명확성을 위해, 필수 파라미터가 누락된 경우 예외를 발생시키는 것이 더 안전할 수 있습니다.

🔎 파라미터 검증 추가 제안
 private LocalDate parseStartDate() {
     if (startDateStr != null && !startDateStr.isBlank()) {
         return LocalDate.parse(startDateStr, DATE_FORMATTER);
     }
-    return LocalDate.now().with(DayOfWeek.MONDAY);
+    throw new IllegalArgumentException("startDate 파라미터가 필수입니다");
 }

 private LocalDate parseEndDate() {
     if (endDateStr != null && !endDateStr.isBlank()) {
         return LocalDate.parse(endDateStr, DATE_FORMATTER);
     }
-    return LocalDate.now().with(DayOfWeek.SUNDAY);
+    throw new IllegalArgumentException("endDate 파라미터가 필수입니다");
 }
apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java (1)

10-35: 인스턴스 생성을 위한 빌더 패턴 추가를 권장합니다.

편의 접근자 메서드들은 좋은 설계이지만, 엔티티 인스턴스 생성을 위한 빌더나 팩토리 메서드가 없어서 테스트나 데이터 생성 시 불편할 수 있습니다.

🔎 빌더 패턴 추가 제안
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+
 @Entity
 @Getter
+@Builder
 @Table(name = "product_metrics")
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
 public class ProductMetrics {
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java (1)

13-15: 트랜잭션 컨텍스트 요구사항을 명확히 하는 것을 권장합니다.

@Modifying 쿼리는 트랜잭션 내에서 실행되어야 합니다. 현재 호출자(WeeklyRankingWriter)가 @Transactional을 가지고 있어 동작하지만, 이 메서드가 다른 곳에서 호출될 가능성을 고려하여 JavaDoc으로 트랜잭션 필요성을 문서화하거나, 메서드에 @Transactional을 직접 추가하는 것을 고려해주세요.

🔎 트랜잭션 어노테이션 추가 제안
+import org.springframework.transaction.annotation.Transactional;
+
 public interface WeeklyRankingJpaRepository extends JpaRepository<WeeklyRanking, Long> {

+    @Transactional
     @Modifying
     @Query("DELETE FROM WeeklyRanking w WHERE w.weekStart = :weekStart AND w.weekEnd = :weekEnd")
     void deleteByWeekStartAndWeekEnd(@Param("weekStart") LocalDate weekStart, @Param("weekEnd") LocalDate weekEnd);
 }
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java (1)

75-90: double[] 대신 long[] 사용 권장

likeCount, stockCount, viewCount는 정수 값을 합산하므로 double[] 대신 long[]을 사용하면 불필요한 타입 변환을 피하고 정밀도 문제를 방지할 수 있습니다.

🔎 리팩토링 제안
         Map<Long, double[]> aggregatedMap = summaries.stream()
+        Map<Long, long[]> aggregatedMap = summaries.stream()
                 .collect(Collectors.groupingBy(
                         ProductMetricsSummary::getProductId,
                         Collectors.collectingAndThen(
                                 Collectors.toList(),
                                 list -> {
-                                    double[] sums = new double[3];
+                                    long[] sums = new long[3];
                                     for (ProductMetricsSummary s : list) {
                                         sums[0] += s.getLikeCount();
                                         sums[1] += s.getStockCount();
                                         sums[2] += s.getViewCount();
                                     }
                                     return sums;
                                 }
                         )
                 ));
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankingWriter.java (1)

44-45: 불필요한 캐스팅 제거를 권장합니다.

chunk.getItems()는 이미 List<? extends MonthlyRanking>을 반환하므로, MonthlyRanking으로의 명시적 캐스팅은 불필요합니다. Spring Data JPA의 saveAllIterable<S extends T>를 허용합니다.

🔎 제안된 수정
         log.info("월간 랭킹 저장: count={}", chunk.size());
-        monthlyRankingRepository.saveAll(chunk.getItems().stream().map(item -> (MonthlyRanking) item).toList());
+        monthlyRankingRepository.saveAll(chunk.getItems());
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java (1)

14-23: 네이티브 쿼리의 파라미터 타입 불일치 확인이 필요합니다.

findByPeriodWithPagination 메서드는 monthPeriodString으로 받지만, 같은 리포지토리의 다른 메서드들(findByProductIdAndMonthPeriod, countByMonthPeriod)은 YearMonth를 직접 사용합니다. 호출자가 일관된 타입을 사용할 수 있도록 통일하는 것이 좋습니다.

네이티브 쿼리에서 YearMonth를 직접 사용하려면 YearMonthAttributeConverter가 적용되도록 @Convert 또는 파라미터 변환이 필요합니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java (1)

48-57: 잘못된 period 값에 대한 로깅 추가를 고려해보세요.

현재 유효하지 않은 period 값이 전달되면 조용히 DAILY로 기본값 처리됩니다. 디버깅 및 모니터링을 위해 잘못된 입력값을 로깅하는 것이 좋습니다.

🔎 제안된 수정
     private RankingPeriod parsePeriod(String period) {
         if (period == null || period.isBlank()) {
             return RankingPeriod.DAILY;
         }
         try {
             return RankingPeriod.valueOf(period.toUpperCase());
         } catch (IllegalArgumentException e) {
+            log.warn("Invalid ranking period '{}', defaulting to DAILY", period);
             return RankingPeriod.DAILY;
         }
     }

참고: 클래스에 @Slf4j 어노테이션 추가가 필요합니다.

apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java (2)

18-34: 주간 랭킹 Job과 월간 랭킹 Job 간의 응답 처리 불일치.

runWeeklyRankingJob은 실행 상태를 확인하지 않고 바로 "started"를 반환하지만, runMonthlyRankingJobJobExecution 상태를 확인합니다. 동일한 패턴으로 일관성 있게 처리하는 것이 좋습니다.

🔎 제안된 수정
     @PostMapping("/weekly-ranking")
     public ResponseEntity<String> runWeeklyRankingJob(
             @RequestParam String startDate,
             @RequestParam String endDate
     ) throws Exception {

         JobParameters params = new JobParametersBuilder()
                 .addString("startDate", startDate)
                 .addString("endDate", endDate)
                 .addLong("timestamp", System.currentTimeMillis()) // 중복 실행 방지
                 .toJobParameters();

         Job job = jobRegistry.getJob("weeklyRankingJob");
-        jobLauncher.run(job, params);
+        JobExecution execution = jobLauncher.run(job, params);

-        return ResponseEntity.ok("WeeklyRankingJob started!");
+        if (execution.getStatus() == BatchStatus.FAILED) {
+            return ResponseEntity.status(500).body("weeklyRankingJob failed!");
+        }
+
+        return ResponseEntity.ok("weeklyRankingJob started!");
     }

19-22: 입력 파라미터 유효성 검증 부재.

startDate, endDate, yearMonth 파라미터에 대한 형식 검증이 없습니다. 잘못된 형식의 입력이 배치 작업 내부에서 파싱 오류를 일으킬 수 있으며, 이 경우 사용자에게 불명확한 오류 메시지가 전달될 수 있습니다.

🔎 제안 예시 (yearMonth 검증)
private void validateYearMonth(String yearMonth) {
    try {
        YearMonth.parse(yearMonth, DateTimeFormatter.ofPattern("yyyy-MM"));
    } catch (DateTimeParseException e) {
        throw new IllegalArgumentException("Invalid yearMonth format. Expected: yyyy-MM");
    }
}

Also applies to: 37-38

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

134-140: @Transactional(readOnly = true) 어노테이션 추가를 권장합니다.

getProductRank(Long productId, LocalDate date, RankingPeriod period) 메서드는 DB 조회를 수행하지만 @Transactional 어노테이션이 없습니다. 다른 조회 메서드들(getRankingPage, getTopN)과의 일관성 및 읽기 전용 최적화를 위해 추가하는 것이 좋습니다.

🔎 제안된 수정
+    @Transactional(readOnly = true)
     public Long getProductRank(Long productId, LocalDate date, RankingPeriod period) {
         return switch (period) {
             case WEEKLY -> rankingService.getWeeklyRank(productId, date);
             case MONTHLY -> rankingService.getMonthlyRank(productId, date);
             default -> rankingService.getRank(productId, date);
         };
     }
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/MonthlyRankingProcessor.java (2)

28-39: parseYearMonth() 호출을 캐싱하여 성능을 개선할 수 있습니다.

parseYearMonth()가 매 process() 호출마다 실행되지만, yearMonth 값은 Job 파라미터로 고정되어 있어 한 번만 파싱하면 됩니다. 필드에 캐싱하여 불필요한 파싱을 방지할 수 있습니다.

🔎 제안된 수정
+    private YearMonth cachedYearMonth;
+
     @Override
     public MonthlyRanking process(RankedProduct item) {
-        YearMonth yearMonth = parseYearMonth();
+        if (cachedYearMonth == null) {
+            cachedYearMonth = parseYearMonth();
+        }

         int rank = rankCounter.incrementAndGet();

         return MonthlyRanking.create(
                 rank,
                 item.productId(),
                 item.score(),
-                yearMonth
+                cachedYearMonth
         );
     }

25-25: AtomicInteger 대신 일반 int 사용을 고려해보세요.

Spring Batch의 ItemProcessor.process()는 단일 스레드에서 순차적으로 호출되므로, AtomicInteger의 원자성 보장이 필요하지 않습니다. 단순한 int 카운터로 충분합니다.

🔎 제안된 수정
-    private final AtomicInteger rankCounter = new AtomicInteger(0);
+    private int rankCounter = 0;

     @Override
     public MonthlyRanking process(RankedProduct item) {
         YearMonth yearMonth = parseYearMonth();

-        int rank = rankCounter.incrementAndGet();
+        int rank = ++rankCounter;

         return MonthlyRanking.create(
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java (1)

14-16: 모듈 간 동일한 클래스명 사용에 대한 검토가 필요합니다.

commerce-apicommerce-batch 모듈 모두에 RankingRepositoryImpl이라는 동일한 이름의 클래스가 존재합니다. 이는 코드 탐색과 유지보수를 어렵게 만들 수 있습니다.

PR 설명에서 언급하신 것처럼, 다음 옵션들을 고려해보세요:

  1. 배치 모듈의 클래스명을 더 구체적으로 변경 (예: BatchRankingRepositoryImpl 또는 RankingWriteRepositoryImpl)
  2. API는 DTO/Projection 사용, Batch가 엔티티를 소유하도록 통합
  3. 공유 도메인 모듈로 엔티티를 분리하여 중복 제거

읽기/쓰기 책임이 명확히 분리된다면 1번 옵션(클래스명 구체화)이 가장 간단한 개선책이 될 수 있습니다.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c91616 and f74bed0.

📒 Files selected for processing (52)
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRanking.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.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/domain/RankingWeightReader.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/metrics/ProductMetricsRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRanking.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRankingRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRankingRepository.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/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/MonthlyRankingProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/processor/WeeklyRankingProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/MonthlyRankingWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.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/dto/ProductMetricsSummary.java
  • apps/commerce-batch/src/main/java/com/loopers/dto/RankedProduct.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/interfaces/batch/BatchController.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java
  • apps/commerce-batch/src/test/java/com/loopers/job/step/DemoJobE2ETest.java
  • apps/commerce-streamer/build.gradle.kts
  • settings.gradle.kts
🧰 Additional context used
🧠 Learnings (6)
📚 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/MonthlyRankingJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java
📚 Learning: 2026-01-01T14:12:07.943Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/JobListener.kt:4-5
Timestamp: 2026-01-01T14:12:07.943Z
Learning: Spring Batch 6.x에서 JobExecution은 org.springframework.batch.core.job 패키지에, JobExecutionListener는 org.springframework.batch.core.listener 패키지에 위치합니다. Spring Batch 5.x와 달리 하위 패키지를 사용합니다.

Applied to files:

  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java
📚 Learning: 2026-01-01T14:12:04.385Z
Learnt from: ghojeong
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 76
File: apps/commerce-batch/src/main/kotlin/com/loopers/batch/listener/ChunkListener.kt:4-5
Timestamp: 2026-01-01T14:12:04.385Z
Learning: In Spring Batch 6.x, ChunkListener API changed from using ChunkContext to Chunk<T> parameters. The correct method signatures are: beforeChunk(Chunk<T> chunk), afterChunk(Chunk<T> chunk), and onChunkError(Exception exception, Chunk<T> chunk). The import path is org.springframework.batch.core.listener.ChunkListener.

Applied to files:

  • 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/listener/ChunkListener.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
📚 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/domain/metrics/ProductMetrics.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/resources/application.yml
🔇 Additional comments (48)
apps/commerce-batch/src/main/resources/application.yml (3)

1-18: 배치 애플리케이션 기본 설정이 적절합니다.

web-application-type: none으로 웹 서버를 비활성화하고, job.name: ${job.name:NONE} 템플릿으로 기본적으로 모든 작업 실행을 방지하는 설정이 Spring Batch 모범 사례를 따르고 있습니다.


25-32: 로컬/테스트 환경의 스키마 자동 초기화 설정이 적절합니다.

개발 및 테스트 환경에서 initialize-schema: always를 사용하여 배치 메타데이터 테이블을 자동 생성하는 것은 합리적인 접근입니다.


52-54: Springdoc 비활성화가 적절합니다.

배치 애플리케이션에서 웹 엔드포인트가 없으므로 API 문서 생성을 비활성화하는 것이 맞습니다.

apps/commerce-batch/build.gradle.kts (4)

10-10: 배치 모듈에서 웹 스타터 의존성 확인 필요

배치 모듈에 spring-boot-starter-web을 포함하는 것은 일반적이지 않습니다. PR 설명에서 "manual batch controller"와 "BatchController" 엔드포인트를 언급하고 있어 의도적인 것으로 보이나, 다음을 확인해 주세요:

  • 수동 트리거용 REST API가 필요한 경우, 이 접근 방식이 적절합니다.
  • 만약 순수 스케줄링 기반 배치만 필요하다면, 웹 의존성을 제거하고 경량화하는 것을 고려해 보세요.

12-14: 배치 의존성 구성 적절함

Spring Batch 의존성이 올바르게 구성되어 있습니다. 테스트 의존성도 적절하게 testImplementation 스코프로 설정되었습니다.


21-23: 테스트 픽스처 구성 적절함

JPA 및 Redis 모듈의 테스트 픽스처를 활용하여 배치 컴포넌트의 통합 테스트를 지원하는 구성이 적절합니다.


17-19: QueryDSL 의존성 버전 관리 검증 필요

querydsl-apt::jakarta 구문의 더블 콜론 표기법(classifier)은 버전이 BOM, 플랫폼, 또는 명시적 버전 선언에서 관리될 때 사용됩니다. 루트 빌드 파일에서 QueryDSL 버전 관리 여부와 Spring Boot BOM이 이를 포함하는지 확인해 주세요. 버전 관리가 없으면 빌드가 실패할 수 있습니다.

apps/commerce-streamer/build.gradle.kts (1)

14-14: OpenAPI 문서화 의존성 추가 승인.

랭킹 API 확장에 맞춰 SpringDoc OpenAPI UI 의존성을 추가한 것은 적절합니다. webmvc-ui 아티팩트는 Spring MVC 기반 애플리케이션에 적합한 선택입니다.

SpringDoc OpenAPI 자체에는 직접적인 공개 CVE가 없으나, 프로젝트 속성 springDocOpenApiVersion이 정의되어 있는지 확인이 필요합니다. 또한 사용 중인 특정 버전의 전이적 의존성(transitive dependencies)에 대한 보안 검증도 권장됩니다.

settings.gradle.kts (1)

6-6: 새 배치 모듈 등록이 올바르게 추가되었습니다.

기존 모듈 네이밍 패턴과 일관성 있게 commerce-batch 모듈이 추가되었습니다.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java (1)

3-7: 기간 타입 열거형이 명확하게 정의되었습니다.

일별, 주별, 월별 랭킹 조회를 구분하기 위한 열거형이 깔끔하게 구현되었습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java (1)

11-20: 테이블 설계가 잘 되어 있습니다.

인덱스와 유니크 제약조건이 랭킹 조회 및 중복 방지에 적합하게 구성되어 있습니다.

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

15-19: 전역 타임존 설정이 적절하게 구성되었습니다.

배치 애플리케이션에서 일관된 날짜/시간 처리를 위해 전역 타임존을 설정하는 것은 적절합니다. 다만, TimeZone.setDefault()는 JVM 전체에 영향을 미치므로 문서화가 중요합니다.


21-24: 배치 애플리케이션의 종료 패턴이 올바릅니다.

SpringApplication.exit()로 컨텍스트를 정리하고 종료 코드를 얻은 후 System.exit()로 JVM을 종료하는 패턴은 스케줄러와의 통합에 적합합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/RankingWeightReader.java (2)

8-25: 문서화와 상수 정의가 명확합니다.

commerce-streamer와의 호환성 언급과 기본 가중치 값이 잘 문서화되어 있습니다.


27-37: weight 메서드의 호출 패턴 확인이 필요합니다.

이 메서드들이 배치 처리 루프 내에서 반복 호출되는지 확인하세요. 호출 빈도가 높은 경우, 배치 작업 시작 시 한 번만 조회하여 캐싱하는 방안을 검토하세요. 특히 getWeight() 메서드 구현과 실제 사용처를 분석하여 최적화 필요성을 판단하세요.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/converter/YearMonthAttributeConverter.java (1)

8-8: YearMonth 필드에 autoApply 적용 시 형식 일관성 확인 필요

@Converter(autoApply = true)는 프로젝트 내 모든 YearMonth 필드에 자동으로 "yyyy-MM" 형식 변환을 적용합니다. JPA 모범 사례에 따르면, autoApply는 변환이 해당 Java 타입의 모든 사용처에서 동일하게 적용되어야 할 때만 사용하는 것이 권장됩니다. 서로 다른 날짜 형식이 필요한 YearMonth 필드가 있거나, 특정 필드에만 다른 변환을 적용해야 하는 경우 충돌이 발생할 수 있습니다.

다음을 확인하세요:

  • 프로젝트 내 모든 YearMonth 필드가 "yyyy-MM" 형식을 사용하는지
  • 다른 형식의 YearMonth 변환이 필요한 필드가 있는지
  • 필드별 @Convert 명시가 필요한 경우가 있는지

일관된 형식이 확실하면 autoApply=true는 적절하지만, 예외가 있다면 명시적인 @Convert 어노테이션 사용을 고려하세요.

apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTests.java (1)

6-11: LGTM!

애플리케이션 컨텍스트 로드 테스트가 정상적으로 구성되어 있습니다. 향후 실제 배치 작업(주간/월간 랭킹 작업)에 대한 통합 테스트를 추가하면 더욱 견고한 테스트 커버리지를 확보할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetricsRepository.java (1)

8-9: LGTM!

리포지토리 인터페이스가 명확하고 간결하게 정의되어 있습니다. 날짜 범위 기반 메트릭 조회를 위한 메서드 시그니처가 적절하며, 프로젝션 DTO(ProductMetricsSummary)를 반환하여 필요한 데이터만 조회하는 것이 좋습니다.

apps/commerce-batch/src/main/java/com/loopers/dto/RankedProduct.java (1)

3-6: productIdscore 필드의 null 안전성 및 유효성 검증 확인 필요합니다.

productIdLong (nullable) 타입이므로 null 값이 허용되지만, 배치 프로세서(MonthlyRankingProcessor, WeeklyRankingProcessor)에서 이를 사용할 때 NPE가 발생할 수 있습니다. 프로세서 구현에서 명시적인 null 검증이 있는지 확인하고, score 필드의 유효 범위(음수 허용 여부 등) 제약 조건을 명확히 해야 합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/MonthlyRankingRepository.java (1)

6-10: 리포지토리 인터페이스 설계가 명확합니다.

YearMonth 타입을 사용하여 월별 기간을 표현하는 것이 타입 안전성 측면에서 우수하며, deleteByMonthPeriodsaveAll 패턴은 기존 랭킹을 교체하는 전략으로 적절합니다.

다만 배치 writer(MonthlyRankingWriter)에서 이 두 메서드가 단일 트랜잭션 내에서 실행되는지 확인이 필요합니다. 삭제와 저장 사이에 트랜잭션이 분리되면 일관성 문제가 발생할 수 있으므로, MonthlyRankingWriter의 트랜잭션 경계를 검토해주세요.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java (1)

11-21: 구현이 깔끔합니다.

표준 위임 패턴을 사용하여 도메인 저장소를 구현했습니다. 코드가 명확하고 적절합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/MonthlyRankingJpaRepository.java (1)

11-16: AI 요약과 코드 간 불일치가 있습니다.

AI 요약에는 findByPeriodWithPagination, findByProductIdAndMonthPeriod, countByMonthPeriod 메서드가 언급되어 있지만, 제공된 코드에는 존재하지 않습니다. 이는 코드가 불완전하거나 요약이 부정확함을 나타냅니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRankingRepository.java (1)

6-11: 인터페이스가 명확합니다.

도메인 저장소 인터페이스가 적절하게 정의되어 있으며, 메서드 시그니처가 명확합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/MonthlyRankingReader.java (2)

26-26: 수동 확인이 필요합니다.

ProductMetricsJpaRepository 타입이 사용되고 있지만 import 문이 없는지 확인이 필요합니다. 또한 다음 사항도 함께 검토하세요:

  • ProductMetricsJpaRepository의 정확한 패키지 위치 확인
  • 63번 줄에서 호출하는 메서드 이름 (findAllByProductIdAndDateBetween)이 리포지토리에 실제로 존재하는지 확인
  • 필요한 import 문이 누락되었는지 확인

63-63: 메서드 이름 불일치를 확인하세요.

findAllByProductIdAndDateBetween 메서드를 호출하고 있지만, ProductMetricsRepositoryImpl에 정의된 실제 메서드 시그니처를 확인해야 합니다. 메서드 이름이 일치하지 않으면 컴파일 오류를 유발할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)

43-44: 트랜잭션 매니저 선택을 검토하세요.

ResourcelessTransactionManager는 트랜잭션 지원이 없는 매니저입니다. 현재 DemoTasklet은 단순 작업만 수행하므로 적절하지만, 향후 데이터베이스 작업이나 트랜잭션이 필요한 로직을 추가할 경우 적절한 PlatformTransactionManager로 변경해야 합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java (1)

18-21: LGTM!

Step 시작 로깅 로직이 명확하고 적절합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/writer/WeeklyRankingWriter.java (1)

24-34: 일회성 삭제 플래그 패턴이 적절합니다.

@StepScope가 Step 실행마다 새 인스턴스를 생성하므로, deletedExisting 플래그를 사용한 일회성 삭제 로직이 올바르게 동작합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/domain/metrics/ProductMetrics.java (1)

10-27: 복합 키 패턴과 델타 필드 구조가 적절합니다.

@EmbeddedId를 사용한 복합 키 설계와 델타 필드들의 구조가 메트릭 집계에 적합합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java (1)

22-59: LGTM! Spring Batch Job 설정이 적절합니다.

  • ConditionalOnProperty로 선택적 활성화
  • RunIdIncrementer로 Job 인스턴스 관리
  • CHUNK_SIZE = 100이 Reader의 TOP_N = 100과 일치하여 단일 청크로 처리
  • Job/Step 리스너가 적절히 연결됨
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java (1)

8-23: LGTM! 도메인 레포지토리 인터페이스가 깔끔하게 설계되었습니다.

  • 주간/월간 랭킹에 대한 메서드가 명확하게 분리됨
  • 주간은 LocalDate 쌍, 월간은 YearMonth를 사용하여 각 기간의 특성을 잘 반영함
  • Spring Data 의존성 없이 도메인 계층의 순수성 유지
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java (1)

67-82: LGTM! 하위 호환성을 고려한 좋은 설계입니다.

TopNResponse.of 메서드의 오버로드를 통해 기존 코드와의 하위 호환성을 유지하면서 새로운 period 파라미터를 추가했습니다. RankingPeriod.DAILY를 기본값으로 사용한 것은 적절합니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java (1)

13-31: LGTM! 쿼리 프로젝션이 잘 구현되었습니다.

JPQL DTO 프로젝션 패턴이 적절하게 사용되었습니다. 한 가지 확인 필요:

ProductMetrics 엔티티의 likesDelta, salesDelta, viewsDelta 필드 타입을 확인하여, 이미 Long 타입이라면 CAST(... AS long) 구문을 제거할 수 있습니다.

apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1ApiSpec.java (1)

29-31: period 파라미터의 유효성 검증 구현 확인 필요

period 파라미터가 String 타입으로 정의되어 있어 daily, weekly, monthly 외의 잘못된 값이 전달될 수 있습니다. Controller 레벨에서 RankingPeriod.valueOf() 변환 또는 다른 검증 메커니즘이 적절하게 구현되어 있는지 확인이 필요합니다. 가능하면 enum 타입으로 파라미터를 정의하는 것을 고려해 주세요.

apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/WeeklyRankingJpaRepository.java (1)

14-24: Native 쿼리의 데이터베이스 의존성 고려

LIMIT :limit OFFSET :offset 구문은 MySQL/PostgreSQL에 특화된 문법으로, 다른 데이터베이스로의 마이그레이션 시 호환성 문제가 발생할 수 있습니다. 현재 프로젝트에서 특정 데이터베이스만 지원하는 경우 문제가 없지만, 향후 이식성이 필요하다면 Spring Data의 Pageable 인터페이스 사용을 고려해 주세요.

또한 weekStartweekEnd 컬럼에 대한 복합 인덱스 설정을 확인하고, 필요시 추가하여 페이지네이션 성능을 최적화해 주세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/reader/WeeklyRankingReader.java (1)

110-124: 주간 랭킹 기본값 로직 재검토 권장

현재 구현에서 parseStartDate()parseEndDate()는 당일 기준 현재 주의 월요일부터 일요일까지를 반환합니다. 배치 작업이 주중(예: 수요일)에 실행되면 아직 도래하지 않은 날짜의 데이터까지 포함하게 되어, 일반적인 주간 랭킹 집계 관행과 맞지 않을 수 있습니다. 배치 실행 일정을 확인하고, 필요시 지난 주 데이터를 집계하도록 로직을 수정하는 것을 검토해 주세요.

apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (1)

22-59: 잘 구성된 Spring Batch Job 설정입니다.

ConditionalOnProperty를 사용하여 job 단위로 빈 활성화를 제어하고, chunk 기반 처리와 리스너 구성이 적절합니다. RunIdIncrementer를 통해 동일 파라미터로도 재실행이 가능합니다.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (1)

35-48: 기간별 랭킹 조회 로직이 깔끔하게 구현되었습니다.

switch 표현식을 활용하여 RankingPeriod에 따른 분기 처리가 명확하고 확장 가능한 구조입니다.

apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPageInfo.java (1)

4-36: LGTM! 하위 호환성을 유지하는 깔끔한 구현입니다.

RankingPeriod를 추가하면서도 기존 API 호출을 위한 기본값(DAILY) 제공 팩토리 메서드를 유지하여 하위 호환성을 잘 보장했습니다.

apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java (2)

19-32: LGTM! 주간 랭킹 조회 메서드가 명확하게 구현되었습니다.

페이지네이션, 개별 상품 조회, 카운트 조회 메서드가 적절하게 위임되고 있습니다.


34-47: month_period 컬럼의 실제 저장 형식 확인 필요

Line 36에서 period.toString()을 사용하고 있습니다. YearMonth.toString()은 표준적으로 "yyyy-MM" 형식을 반환하지만, MonthlyRanking 엔티티의 month_period 컬럼 정의와 findByPeriodWithPagination() 메서드의 실제 쿼리 구현을 확인하여 다음을 검증하세요:

  • month_period 컬럼이 실제로 "yyyy-MM" 형식으로 저장되는지
  • JPA 쿼리 메서드가 YearMonth 형식 변환을 올바르게 처리하는지
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingCommand.java (2)

30-39: LGTM! 기간 파싱 로직이 안전하게 구현되었습니다.

null, 빈 문자열, 잘못된 값에 대해 모두 DAILY로 기본값 처리하며, 대소문자 구분 없이 파싱하는 것이 사용자 친화적입니다.


16-24: 페이지네이션 파라미터 검증이 제거되었습니다. 다른 레이어에서 검증하는지 확인하세요.

RankingCommand.of() 메서드에서 pagesize 파라미터에 대한 제약이 없습니다. 제거된 MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE 등의 검증 로직이 컨트롤러 레이어나 다른 곳에서 수행되는지 확인하세요:

  • page >= 0
  • size > 0 && size <= MAX_SIZE

음수값이나 과도하게 큰 값이 전달될 수 있으므로 API 계층에서 유효성 검사를 보장해야 합니다.

apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java (4)

20-24: LGTM! 상수 추출로 가독성과 유지보수성이 향상되었습니다.

Redis 키 접두사와 날짜 포맷터를 상수로 추출하여 일관성을 보장합니다.


26-87: LGTM! 일일 랭킹 메서드가 새로운 상수를 사용하도록 리팩토링되었습니다.

기존 로직은 유지하면서 키 생성 방식만 일관되게 개선했습니다.


130-165: LGTM! 월간 랭킹 조회 로직이 주간 랭킹과 일관된 패턴으로 구현되었습니다.

YearMonth를 사용한 월간 기간 처리가 명확하고, 페이지네이션, 개별 조회, 카운트 메서드가 모두 적절하게 구현되었습니다.


89-128: 주간 랭킹 조회 로직의 주간 경계 일관성 확인 필요

라인 90-91에서 DayOfWeek.MONDAYDayOfWeek.SUNDAY를 사용하여 주간 범위를 설정하는 것은 올바른 구현입니다. 다만 배치 작업에서 주간 랭킹을 계산할 때 동일한 주간 경계(월요일 시작, 일요일 종료)를 사용하는지 확인하세요. 배치와 API에서 주간 범위 정의가 다르면 데이터 불일치가 발생할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java (1)

21-40: 배치 쓰기 작업의 트랜잭션 범위 확인이 필요합니다.

이 repository의 메서드들이 delete-then-insert 패턴으로 멱등성을 보장하려면, 배치 Job 또는 Step 레벨에서 명시적인 @Transactional 경계가 설정되어야 합니다. 부분 실패 시 데이터 일관성을 보장하기 위해 다음을 확인하세요:

  • 배치 작업을 호출하는 Job/Step 클래스에 @Transactional 어노테이션이 있는지
  • transaction manager가 적절히 설정되어 있는지
  • 삭제와 저장 작업이 하나의 트랜잭션으로 처리되는지

Comment on lines +10 to +30
@Entity
@Getter
@Table(name = "mv_product_rank_monthly")
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class MonthlyRanking {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "monthly_rank")
private int rank;

private Long productId;

private double score;

@Convert(converter = YearMonthAttributeConverter.class)
@Column(name = "month_period")
private YearMonth monthPeriod;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

인스턴스 생성을 위한 빌더 또는 팩토리 메서드 추가를 고려해주세요.

현재 protected 기본 생성자만 제공되어 있어, 배치 작업에서 MonthlyRanking 인스턴스를 생성하기 어렵습니다. 공개 생성자, 빌더 패턴, 또는 정적 팩토리 메서드를 추가하는 것을 권장합니다.

🔎 빌더 패턴 추가 제안
+import lombok.Builder;
+
 @Entity
 @Getter
+@Builder
 @Table(name = "mv_product_rank_monthly")
 @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
+@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE)
 public class MonthlyRanking {
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRanking.java
around lines 10 to 30, the entity only exposes a protected no-arg constructor
which makes creating instances from batch code difficult; add a public
constructor or a static factory method and/or a Lombok @Builder to allow easy,
explicit instance creation with required fields (id optional) — implement a
public constructor or static of(...) that accepts rank, productId, score,
monthPeriod (and optionally id), or annotate the class with @Builder and add a
public all-args constructor so callers can construct MonthlyRanking instances in
batch jobs.

Comment on lines +9 to +12
@Entity
@Getter
@Table(name = "mv_product_rank_weekly")
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
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

엔티티 정의가 배치 모듈과 다릅니다.

이 엔티티는 배치 모듈의 WeeklyRanking과 동일한 테이블을 매핑하지만 인덱스와 제약조건 정의가 없습니다. API 모듈은 읽기 전용이므로 이러한 정의가 필요하지 않지만, 엔티티 중복 자체가 문제입니다 (배치 모듈 파일 리뷰 참조).

Comment on lines +9 to +50
@Entity
@Getter
@Table(
name = "mv_product_rank_weekly",
indexes = {
@Index(name = "idx_week", columnList = "week_start, week_end"),
@Index(name = "idx_score", columnList = "score")
},
uniqueConstraints = {
@UniqueConstraint(name = "uq_weekly_rank", columnNames = {"product_id", "week_start", "week_end"})
}
)
@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED)
public class WeeklyRanking {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "weekly_rank")
private int rank;

private Long productId;

private double score;

private LocalDate weekStart;

private LocalDate weekEnd;

public static WeeklyRanking create(int rank, Long productId, double score, LocalDate weekStart, LocalDate weekEnd) {
WeeklyRanking weeklyRanking = new WeeklyRanking();

weeklyRanking.rank = rank;
weeklyRanking.productId = productId;
weeklyRanking.score = score;
weeklyRanking.weekStart = weekStart;
weeklyRanking.weekEnd = weekEnd;

return weeklyRanking;
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

엔티티 중복을 해소하는 것을 권장합니다.

WeeklyRanking 엔티티가 batch 모듈과 API 모듈에 각각 존재합니다. 이로 인해:

  • 스키마 변경 시 두 곳을 동기화해야 하는 유지보수 부담 증가
  • 두 엔티티가 diverge할 위험 존재 (batch는 factory와 제약조건 보유, API는 bare-bones)

PR 목표에서 언급하신 대로, 다음 옵션을 고려하세요:

  1. Batch 모듈이 엔티티를 소유하고 API는 DTO/projection 사용
  2. 공통 모듈에 엔티티를 두고 양쪽에서 의존

batch 작업이 write를 담당하므로 옵션 1(Batch가 엔티티 소유)이 더 적합해 보입니다.

🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/domain/ranking/WeeklyRanking.java
around lines 9-50, there are duplicate WeeklyRanking entities across the batch
and API modules which risks divergence and extra maintenance; resolve this by
making the batch module the canonical owner of the entity and refactor the API
module to stop using its own entity: remove the WeeklyRanking class from the API
module, replace API references with DTOs or JPA projections that map only needed
fields, update API service/repository code to fetch projections or convert
batch-owned entities into DTOs, and adjust module dependencies so the API
depends on batch DTO/projection interfaces (or expose a read-only module that
contains only DTOs/projections) without importing the batch persistence
internals; ensure tests and schema migrations remain aligned with the
batch-owned entity and update any import/package references accordingly.

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

두 가지 critical 이슈를 수정해야 합니다.

  1. 잘못된 문자열 포맷팅 (Line 17-18): Java에서는 ${} 구문이 문자열 보간(interpolation)을 지원하지 않습니다. 이 코드는 컴파일되지만 로그에 리터럴 "${chunkContext.stepContext...}"가 그대로 출력됩니다.

  2. Spring Batch 6.x API 호환성 문제: Spring Batch 6.x에서는 ChunkListener의 메서드 시그니처가 ChunkContext에서 Chunk<T>로 변경되었습니다. 현재 코드는 이전 API를 사용하고 있어 Spring Batch 6.x 환경에서 리스너가 호출되지 않을 수 있습니다.

Based on learnings, Spring Batch 6.x requires Chunk parameters instead of ChunkContext.

🔎 수정 제안
 package com.loopers.batch.listener;

 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.batch.core.annotation.AfterChunk;
-import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.item.Chunk;
 import org.springframework.stereotype.Component;

 @Slf4j
 @RequiredArgsConstructor
 @Component
 public class ChunkListener {

     @AfterChunk
-    void afterChunk(ChunkContext chunkContext) {
-        log.info(
-                "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " +
-                        "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}"
-        );
+    <T> void afterChunk(Chunk<T> chunk) {
+        int itemCount = chunk.getItems().size();
+        log.info("청크 종료: processedItemCount: {}", itemCount);
     }
 }

참고: Spring Batch 6.x에서는 chunk 객체 자체에서 read/write count를 직접 얻을 수 없으므로, chunk의 아이템 개수를 로깅합니다. Step execution 정보가 필요한 경우 StepExecution@BeforeChunk@AfterChunk 메서드에 추가 파라미터로 받을 수 있습니다.

📝 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}"
);
}
package com.loopers.batch.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.annotation.AfterChunk;
import org.springframework.batch.item.Chunk;
import org.springframework.stereotype.Component;
@Slf4j
@RequiredArgsConstructor
@Component
public class ChunkListener {
@AfterChunk
<T> void afterChunk(Chunk<T> chunk) {
int itemCount = chunk.getItems().size();
log.info("청크 종료: processedItemCount: {}", itemCount);
}
}
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
around lines 14-20, the listener uses Java string interpolation (${...}) and the
old Spring Batch ChunkContext API; update the @AfterChunk method signature to
the Spring Batch 6.x form (e.g., void
afterChunk(org.springframework.batch.core.scope.chunk.Chunk<?> chunk) and
optionally add StepExecution as a second parameter if you need step counters),
replace the interpolated string with a proper logger placeholder or
concatenation (e.g., log.info("청크 종료: itemsCount: {}", chunk.getItems().size())
or include stepExecution.getReadCount()/getWriteCount() when StepExecution is
provided), and ensure imports/reference types are adjusted to the new API.

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

문자열 보간 구문 오류를 수정하세요.

Line 21의 '${jobExecution.jobInstance.jobName}' 구문은 Java에서 유효하지 않습니다. 문자열 연결이나 String.format()을 사용해야 합니다.

🔎 제안하는 수정
 @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 message uses a Groovy-style interpolation
('${jobExecution.jobInstance.jobName}') which is invalid in Java; replace it
with a valid Java expression such as using String concatenation or
String.format, and call the proper getters
(jobExecution.getJobInstance().getJobName()), or use the logger's parameterized
form (e.g., log.info("Job {} 시작", jobExecution.getJobInstance().getJobName()));
ensure the final logged string includes the job name correctly.

Comment on lines +25 to +52
@AfterJob
void afterJob(JobExecution jobExecution) {
var startTime = jobExecution.getExecutionContext().getLong("startTime");
var endTime = System.currentTimeMillis();

var startDateTime = Instant.ofEpochMilli(startTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
var endDateTime = Instant.ofEpochMilli(endTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();

var totalTime = endTime - startTime;
var duration = Duration.ofMillis(totalTime);
var hours = duration.toHours();
var minutes = duration.toMinutes() % 60;
var seconds = duration.getSeconds() % 60;

var message = String.format(
"""
*Start Time:* %s
*End Time:* %s
*Total Time:* %d시간 %d분 %d초
""", startDateTime, endDateTime, hours, minutes, seconds
).trim();

log.info(message);
}
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

ExecutionContext에서 값을 안전하게 가져오세요.

Line 27에서 getLong("startTime")을 호출하는데, beforeJob이 실패하거나 호출되지 않은 경우 키가 존재하지 않아 예외가 발생할 수 있습니다. 방어적 프로그래밍을 위해 키 존재 여부를 확인하거나 기본값을 사용하세요.

🔎 제안하는 수정
 @AfterJob
 void afterJob(JobExecution jobExecution) {
-    var startTime = jobExecution.getExecutionContext().getLong("startTime");
+    var executionContext = jobExecution.getExecutionContext();
+    if (!executionContext.containsKey("startTime")) {
+        log.warn("startTime이 ExecutionContext에 없습니다. Job 시작 시간을 기록할 수 없습니다.");
+        return;
+    }
+    
+    var startTime = executionContext.getLong("startTime");
     var endTime = System.currentTimeMillis();
     
     var startDateTime = Instant.ofEpochMilli(startTime)
📝 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
@AfterJob
void afterJob(JobExecution jobExecution) {
var startTime = jobExecution.getExecutionContext().getLong("startTime");
var endTime = System.currentTimeMillis();
var startDateTime = Instant.ofEpochMilli(startTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
var endDateTime = Instant.ofEpochMilli(endTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
var totalTime = endTime - startTime;
var duration = Duration.ofMillis(totalTime);
var hours = duration.toHours();
var minutes = duration.toMinutes() % 60;
var seconds = duration.getSeconds() % 60;
var message = String.format(
"""
*Start Time:* %s
*End Time:* %s
*Total Time:* %d시간 %d분 %d초
""", startDateTime, endDateTime, hours, minutes, seconds
).trim();
log.info(message);
}
@AfterJob
void afterJob(JobExecution jobExecution) {
var executionContext = jobExecution.getExecutionContext();
if (!executionContext.containsKey("startTime")) {
log.warn("startTime이 ExecutionContext에 없습니다. Job 시작 시간을 기록할 수 없습니다.");
return;
}
var startTime = executionContext.getLong("startTime");
var endTime = System.currentTimeMillis();
var startDateTime = Instant.ofEpochMilli(startTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
var endDateTime = Instant.ofEpochMilli(endTime)
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
var totalTime = endTime - startTime;
var duration = Duration.ofMillis(totalTime);
var hours = duration.toHours();
var minutes = duration.toMinutes() % 60;
var seconds = duration.getSeconds() % 60;
var message = String.format(
"""
*Start Time:* %s
*End Time:* %s
*Total Time:* %d시간 %d분 %d초
""", startDateTime, endDateTime, hours, minutes, seconds
).trim();
log.info(message);
}
🤖 Prompt for AI Agents
In apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
around lines 25 to 52, the code calls
jobExecution.getExecutionContext().getLong("startTime") directly which can throw
if the key is missing (e.g., beforeJob didn't run); update to defensively read
the ExecutionContext: check containsKey("startTime") first and if present read
the long, otherwise fall back to a safe default (for example
jobExecution.getStartTime().toEpochMilli() or System.currentTimeMillis()), and
use that value for the subsequent time/duration calculations so the method never
throws when the key is absent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant