⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content

[volume-10] Collect, Stack, Zip#235

Open
junoade wants to merge 14 commits intoLoopers-dev-lab:junoadefrom
junoade:week10-main
Open

[volume-10] Collect, Stack, Zip#235
junoade wants to merge 14 commits intoLoopers-dev-lab:junoadefrom
junoade:week10-main

Conversation

@junoade
Copy link
Collaborator

@junoade junoade commented Jan 1, 2026

📌 Summary

  • 스프링배치를 사용하여 MV 테이블에 적재하고 조회 API를 개선하였습니다.
  • 대량의 일별 메트릭 데이터를 기반으로 주간/월간 랭킹을 사전에 집계
  • 조회 시점에는 집계 연산 없이 빠르게 랭킹을 제공
  • 향후 랭킹 정책 및 기간 확장에 대응 가능한 구조 확보

💬 Review Points

  1. 과제 설계
image
  • Chunk-Oriented 방식을 사용해서 Reader, Processor, Writer 의 구조를 적용해보고, Input (score 미존재) 과 Output (score 존재)의 dto를 다르게 해보는 등 적용해보았습니다.
  • 9주차 과제 구성에 배치 모듈을 더하면서 점점 구성이 복잡해지고 있는게 느껴졌습니다..! 혹시 복잡한 시스템 요소들을 다루시는 팁 같은게 있을까요?
  1. 테이블 설계
  • product_like_metrics 은 일별로 다양한 상품 관련 메트릭 정보를 집계할 수 있도록 product_metrics로 리팩토링 하였습니다.

  • MV 테이블은 다음과 같이 설계하였습니다.

    • 테이블에 대한 엔티티 객체는 commerce-api 내 존재하는 api에서만 사용하여 해당 관심사가 commerce-api에 적합하다 보고 그쪽에 위치시켰습니다. 다만 ranking 관련 소스들이 산재해있어 별도의 모듈로 빼면 좋을 것 같은데 진행해볼 생각입니다.
erDiagram 
MV_PRODUCT_RANK_WEEKLY {
        varchar year_week PK
        bigint  product_id PK
        bigint  view_count
        bigint  like_count
        bigint  order_count
        double score
        datetime created_at
        datetime updated_at
    }

    MV_PRODUCT_RANK_MONTHLY {
        varchar year_month PK
        bigint  product_id PK
        bigint  view_count
        bigint  like_count
        bigint  order_count
        double score
        datetime created_at
        datetime updated_at
    }
Loading
  1. 배치 모듈 구성
  • 배치 모듈을 체리픽해와서 구성하였습니다.
  • 스프링배치에 대해 학습하고 JobConfig, Reader, Processor, Writer, Listener 구조를 익혀서 적용해볼 수 있었습니다.
  1. 배치를 이용한 주간/월별 갱신 전략
  • 현재는 단순하게 일회성 일괄 집계만 하도록 하였지만 추후 증분 + 주기적 전체 재집계(보정)으로 확장해볼 수도 있을 것 같습니다.
  1. 조회 API 구현
  • 기존 일별 레디스를 통한 랭킹 조회 코드와의 수정 범위를 고려하여
  • 전략 패턴을 적용해서 Ranking 정보를 다른 저장소에서 가져올 수 있도록 구현하였습니다.
period=DAILY → Redis(ZSET)
period=WEEKLY|MONTHLY → MV 테이블

✅ Checklist

🧱 Spring Batch

  • Spring Batch Job 을 작성하고, 파라미터 기반으로 동작시킬 수 있다.
java -jar apps/commerce-batch/build/libs/commerce-batch.jar \
  --spring.profiles.active=local \
  --spring.batch.job.enabled=true \
  --spring.batch.job.name=rankWeeklyMvJob \
  yearWeek=2026-W01

java -jar apps/commerce-batch/build/libs/commerce-batch.jar \
  --spring.profiles.active=local \
  --spring.batch.job.enabled=true \
  --spring.batch.job.name=rankMonthlyMvJob \
  yearMonth=202601 startDate=20260101 endDate=20260131
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다.
  • 집계 결과를 저장할 Materialized View 의 구조를 설계하고 올바르게 적재했다.

🧩 Ranking API

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

📎 References

스프링배치 https://youtu.be/gTQSKYL_AGw?si=MD0uBmLYBsnDVoRH

hubtwork and others added 14 commits December 31, 2025 19:26
주간/월간 MV 랭킹 메트릭 정보 집계를 위해
아래의 테이블구조를 따르도록 변경하였습니다.
```mermaid
erDiagram

    PRODUCT_METRICS {
        BIGINT product_id PK
        VARCHAR2(8) metrics_date PK
        VARCHAR2(50) METRICS_TYPE PK
        BIGINT count
        TIMESTAMP updated_at
        TIMESTAMP created_at
    }

```
```mermaid
erDiagram {
    MV_PRODUCT_RANK_WEEKLY {
        varchar year_week PK
        bigint  product_id PK
        bigint  view_count
        bigint  like_count
        bigint  order_count
        datetime created_at
        datetime updated_at
    }

    MV_PRODUCT_RANK_MONTHLY {
        varchar year_month PK
        bigint  product_id PK
        bigint  view_count
        bigint  like_count
        bigint  order_count
        datetime created_at
        datetime updated_at
    }

}
```
- Instant / ZonedDateTime / LocalDateTime 학습
청크 기반 / jdbc / 가장 간단하게

```sh
java -jar apps/commerce-batch/build/libs/commerce-batch-8472ca24.jar \
  --spring.profiles.active=local \
  --spring.batch.job.enabled=true \
  --spring.batch.job.name=rankWeeklyMvJob \
  yearWeek=2026-W01
```
다음과 같은 식으로 score를 계산한다

```md
score = 0.1*view + 0.2*like + 0.7*order
```
다음과 같은 식으로 score를 계산한다

```md
score = 0.1*view + 0.2*like + 0.7*order
```
디버깅용에서 StepMonitorListener를 활용하도록 변경
streamer 모듈에서 api 모듈 위치로 변경

streamer 모듈에서 직접적으로 사용하지 않고, metric 정보를 바탕으로 데이터가 집계되며, api 에서 사용되기 때문
[volume-10] Collect, Stack, Zip
@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Walkthrough

순위 조회 기능을 전략 패턴으로 리팩토링하고, 월간/주간 순위 집계를 위한 새로운 배치 애플리케이션(commerce-batch)을 도입하며, 상품 메트릭 모델을 ProductLikeMetrics에서 복합 키 기반의 ProductMetrics로 통합합니다.

Changes

Cohort / File(s) 변경 요약
Ranking API 및 서비스 계층
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java, RankingQuery.java, RankingQueryService.java
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
RankingPeriod 열거형 추가, RankingQuery 레코드 정의, 서비스에서 period 파라미터 지원하도록 리팩토링. API 컨트롤러에 period 파라미터 추가 및 메서드 호출 변경
Ranking 전략 패턴 구현
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java, RankingFetchStrategyResolver.java, DailyRankingFetcher.java, WeeklyRankingFetcher.java, MonthlyRankingFetcher.java
RankingFetchStrategy 인터페이스 정의, 전략 선택자(Resolver) 구현, 일/주/월별 데이터 조회 전략 클래스 세 개 추가
Product Ranking 도메인 엔티티
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java, ProductRankMonthlyId.java, ProductRankWeekly.java, ProductRankWeeklyId.java
월간/주간 순위 테이블 매핑 엔티티 및 복합 키 클래스 네 개 추가
Product Ranking 저장소
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java, ProductRankWeeklyRepository.java
JPA 저장소 두 개 추가, 커스텀 @Query로 RankingEntry 객체 생성하여 반환
Commerce Batch 애플리케이션 - 기본 설정
apps/commerce-batch/build.gradle.kts, src/main/java/com/loopers/CommerceBatchApplication.java, src/main/resources/application.yml
새로운 배치 모듈 프로젝트 생성, Spring Batch 및 의존성 추가, 배치 전용 빈 팩토리 설정(DataSource 별칭), 다중 프로필 YAML 구성
Batch 데이터 초기화
apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java, src/main/resources/db/fixtures/large-product-metric-data.sql
로컬 프로필 실행 시 대량 데이터 생성 초기화기, SQL 픽스처 스크립트 추가
Demo 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java, step/DemoTasklet.java, src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
샘플 배치 작업 및 태스크 정의, E2E 테스트 추가
Monthly Ranking 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java, RankMonthlyProcessor.java, RankMonthlyProcessorConfig.java, RankMonthlyReaderConfig.java, RankMonthlyWriterConfig.java, dto/MonthlyAggRow.java, dto/MonthlyRankRow.java
월간 순위 집계 배치 작업 전체 파이프라인(읽기-처리-쓰기) 및 DTO 정의
Weekly Ranking 배치 작업
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java, step/RankWeeklyProcessor.java, step/RankWeeklyReaderConfig.java, step/RankWeeklyWriterConfig.java, step/dto/WeeklyAggRow.java, step/dto/WeeklyRankRow.java
주간 순위 집계 배치 작업 전체 파이프라인(읽기-처리-쓰기) 및 DTO 정의
Batch 리스너
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java, JobListener.java, StepMonitorListener.java
배치 작업 및 스텝 모니터링 리스너 세 개 추가, 로깅 및 상태 추적
Batch 테스트
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
컨텍스트 로드 테스트 추가
Product Metrics 도메인 리팩토링
apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java, ProductMetricsId.java, ProductMetricsModel.java
(제거) ProductLikeMetricsModel.java
MetricsType 열거형 추가, 복합 키 기반 ProductMetricsId/ProductMetricsModel 도입, 기존 ProductLikeMetricsModel 제거
Product Metrics 저장소
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
(제거) ProductLikeMetricsRepository.java
ProductMetricsRepository 신규 추가, ProductLikeMetricsRepository 제거
Metrics 서비스 및 테스트
apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java
저장소 타입 및 모델 참조 업데이트로 새로운 Metrics 구조 반영, 테스트 코드도 함께 수정
기타
README.md, apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java, settings.gradle.kts
README에 commerce-batch 디렉토리 추가, 불필요한 import 제거, Gradle 모듈 include 추가

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant RankingV1Controller
    participant RankingQueryService
    participant RankingFetchStrategyResolver
    participant RankingFetchStrategy
    participant Repository
    
    Client->>RankingV1Controller: GET /ranking?period=WEEKLY&date=...&size=20
    RankingV1Controller->>RankingQueryService: getDailyPopularProducts(WEEKLY, date, 20)
    RankingQueryService->>RankingFetchStrategyResolver: resolve(WEEKLY, date, 20)
    
    rect rgb(200, 220, 240)
    Note over RankingFetchStrategyResolver: 해석(Parse) 및 정규화
    RankingFetchStrategyResolver->>RankingFetchStrategyResolver: initLocalDate(date)<br/>normalizeSize(20)<br/>yearWeekKey(LocalDate)<br/>build RankingQuery
    end
    
    RankingFetchStrategyResolver->>RankingFetchStrategyResolver: 전략 선택 (WEEKLY → WeeklyRankingFetcher)
    RankingFetchStrategyResolver-->>RankingQueryService: Resolved(RankingQuery, WeeklyFetcher)
    
    rect rgb(220, 240, 220)
    Note over RankingQueryService: 조회 실행
    RankingQueryService->>RankingFetchStrategy: fetchRankingEntries(key, limit)
    RankingFetchStrategy->>Repository: findTopByYearWeek(key, PageRequest(0, 20))
    Repository-->>RankingFetchStrategy: List<RankingEntry>
    RankingFetchStrategy-->>RankingQueryService: List<RankingEntry>
    end
    
    RankingQueryService-->>RankingV1Controller: List<RankingEntry>
    RankingV1Controller-->>Client: HTTP 200 + JSON
Loading
sequenceDiagram
    participant Batch as Spring Batch Scheduler
    participant JobConfig
    participant Reader as JDBC Reader
    participant Processor
    participant Writer as JDBC Writer
    participant DB as Database
    
    Batch->>JobConfig: 월간 순위 배치 작업 시작 (rankMonthlyMvJob)
    JobConfig->>Reader: 청크 크기 1000으로 시작
    
    rect rgb(255, 240, 200)
    Note over Reader,DB: 읽기 단계
    Reader->>DB: product_metrics 쿼리<br/>(startDate, endDate)<br/>월별 집계
    DB-->>Reader: MonthlyAggRow 리스트
    Reader-->>Processor: MonthlyAggRow 배치 (최대 1000개)
    end
    
    rect rgb(240, 220, 240)
    Note over Processor: 처리 단계
    Processor->>Processor: 각 행마다<br/>score = 0.1*view<br/>        + 0.2*like<br/>        + 0.7*order
    Processor-->>Writer: MonthlyRankRow 배치
    end
    
    rect rgb(220, 240, 240)
    Note over Writer,DB: 쓰기 단계
    Writer->>DB: INSERT ... ON DUPLICATE<br/>KEY UPDATE<br/>mv_product_rank_monthly
    DB-->>Writer: 업데이트 완료
    Writer-->>JobConfig: 청크 완료
    end
    
    Note over JobConfig: 모든 청크 처리 완료 후 작업 종료
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #216: 동일한 순위 기능의 API 표면과 서비스(RankingV1Controller, 순위 검색 흐름, 순위 관련 서비스 클래스)를 수정하므로 직접 관련
  • PR #197: 동일한 상품 메트릭 및 이벤트 처리 도메인(ProductMetrics/ProductMetricsId/ProductMetricsModel, EventHandled 및 관련 저장소)을 수정하므로 직접 관련
  • PR #199: 이벤트/아웃박스/멱등성 및 메트릭 도메인(EventHandledService/테스트, 메트릭 모델/저장소, 관련 컨슈머/아웃박스)의 동일 컴포넌트를 수정하므로 직접 관련

Suggested labels

enhancement

Poem

🐰 배치 작업이 쌓여 가고,
전략 패턴으로 순위 흐름이 맑아지니,
메트릭 복합키로 더욱 단단해지네!
일주월의 순위를 자동으로 모으고,
듣는 이들의 이야기를 한곳에 담네. 🌟

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.80% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive PR 제목은 '[volume-10] Collect, Stack, Zip'으로, 실제 변경사항(Spring Batch 기반 랭킹 집계 및 API 개선)의 주요 내용을 명확하게 반영하지 못하고 있습니다. 더 구체적이고 명확한 제목으로 변경하세요. 예: '[feature] Spring Batch 기반 주간/월간 랭킹 MV 적재 및 조회 API 개선' 또는 유사한 표현.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed PR 설명이 제공된 템플릿 구조를 잘 따르고 있으며, 📌 Summary, 💬 Review Points, ✅ Checklist, 📎 References 등 필수 섹션을 모두 포함하고 있습니다.
✨ Finishing touches
  • 📝 Generate docstrings

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java (2)

31-45: 메서드명이 기능과 일치하지 않습니다.

메서드명이 getDailyPopularProducts이지만 이제 RankingPeriod 파라미터를 받아 일간/주간/월간 모든 기간의 랭킹을 처리합니다. 메서드명이 실제 기능을 반영하지 못하고 있어 혼란을 야기할 수 있습니다.

메서드명을 getPopularProducts 또는 getPopularProductsByPeriod로 변경하는 것을 권장합니다.

🔎 메서드명 변경 제안
     @Transactional(readOnly = true)
-    public RankingQueryResponse getDailyPopularProducts(RankingPeriod period, String date, int size) {
+    public RankingQueryResponse getPopularProducts(RankingPeriod period, String date, int size) {
         log.debug("Get {} popular products for {}", period, date);

컨트롤러에서도 호출 부분을 함께 변경해야 합니다.


53-69: null 값이 API 응답에 포함되는 문제 - 수정 필요

findProductSummaryFrom 메서드에서 제품을 찾지 못한 경우 null을 리스트에 추가하는데, 이 리스트가 필터링 없이 RankingQueryResponse에 전달되고 JSON으로 직렬화됩니다. 현재 Jackson 설정에서 null 값을 제외하는 옵션이 없으므로, API 응답의 productLikeSummary 배열에 null 요소가 포함될 것입니다.

다음 중 하나를 수행하세요:

  • null 값을 필터링하여 리스트에서 제거
  • 또는 빈 summary 객체나 기본값으로 대체
  • 또는 @JsonInclude(JsonInclude.Include.NON_NULL) 어노테이션 추가 (비권장)
🟡 Minor comments (4)
apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql-1-3 (1)

1-3: 주석의 날짜 범위가 실제 데이터와 불일치합니다.

주석에는 [2025-01-01, 2026-01-31] 범위라고 되어 있지만, n.n BETWEEN 0 AND 395는 396일을 생성하여 실제로는 2025-01-01 + 395일 = 2026-02-01까지의 데이터가 생성됩니다.

🔎 주석 수정 제안
 -- PRODUCT_METRICS bulk dummy data
--- 범위: [2025-01-01, 2026-01-31]
+-- 범위: [2025-01-01, 2026-02-01] (396일)
 DELETE FROM product_metrics;
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java-16-17 (1)

16-17: yearWeek 컬럼 길이를 8로 수정하세요

yearWeek 필드는 ISO 8601 주간 형식인 "YYYY-Www"으로만 사용되며, 이는 정확히 8자입니다. 현재 지정된 length = 10은 실제 데이터 길이보다 2자 크므로, 다음과 같이 수정해야 합니다:

- @Column(name = "year_week_key", length = 10, nullable = false)
+ @Column(name = "year_week_key", length = 8, nullable = false)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java-5-5 (1)

5-5: 사용하지 않는 import를 제거하세요.

WeeklyAggRow가 import되었지만 이 파일에서 사용되지 않습니다.

🔎 수정 제안
 import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow;
 import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow;
-import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow;
 import lombok.RequiredArgsConstructor;
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyJobConfig.java-5-5 (1)

5-5: 사용하지 않는 import가 있습니다.

WeeklyAggRow는 주간 집계용 DTO로, 월간 집계 Job에서는 사용되지 않습니다. RankWeeklyJobConfig에서 복사할 때 남은 불필요한 import입니다.

🔎 제거 제안
 package com.loopers.batch.job.rankMonthly;
 
 import com.loopers.batch.job.rankMonthly.dto.MonthlyAggRow;
 import com.loopers.batch.job.rankMonthly.dto.MonthlyRankRow;
-import com.loopers.batch.job.rankWeekly.step.dto.WeeklyAggRow;
 import com.loopers.batch.listener.JobListener;
 import com.loopers.batch.listener.StepMonitorListener;
🧹 Nitpick comments (30)
apps/commerce-batch/src/main/resources/application.yml (2)

20-23: 배치 모니터링을 위한 헬스 체크 고려 사항

기본 헬스 체크가 비활성화되어 있습니다. 배치 애플리케이션에서는 일반적이지만, monitoring.yml에서 배치 작업 실행 상태, 실패율 등을 추적할 수 있는 커스텀 헬스 인디케이터나 메트릭이 구성되어 있는지 확인하는 것을 권장합니다.


52-54: 중복된 설정 (선택 사항)

web-application-type: none으로 설정되어 있어 웹 레이어가 없으므로 Springdoc 비활성화 설정은 불필요합니다. 하지만 이 설정은 무해하며 명시적 의도를 표현한다면 유지해도 됩니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java (2)

31-41: 분산 환경에서 ZoneId.systemDefault() 사용 시 시간대 불일치 가능성이 있습니다.

여러 서버에서 다른 시스템 시간대를 사용할 경우 createdAt/updatedAt 값이 일관되지 않을 수 있습니다. ZoneOffset.UTC 사용을 권장합니다.

🔎 UTC 사용 제안
+import java.time.ZoneOffset;
+
 @PrePersist
 protected void onCreate() {
-    LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
+    LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
     this.createdAt = now;
     this.updatedAt = now;
 }

 @PreUpdate
 protected void onUpdate() {
-    this.updatedAt = LocalDateTime.now(ZoneId.systemDefault());
+    this.updatedAt = LocalDateTime.now(ZoneOffset.UTC);
 }

50-52: 동시성 제어 없이 count 증가 시 lost update 가능성이 있습니다.

동일 상품에 대해 동시에 여러 이벤트가 처리되면 일부 증가가 유실될 수 있습니다. 낙관적 락(@Version) 또는 비관적 락 적용을 고려해 주세요.

🔎 낙관적 락 추가 예시
+    @Version
+    private Long version;
+
     public void increase() {
         this.count += 1;
     }
apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java (2)

38-49: 팩토리 메서드가 현재 날짜만 지원합니다.

of(Long, MetricsType) 메서드는 항상 오늘 날짜를 사용합니다. 배치 처리나 과거 데이터 재처리를 위해 날짜를 파라미터로 받는 오버로드 메서드가 필요할 수 있습니다.

🔎 날짜 파라미터를 받는 팩토리 메서드 추가
 public static ProductMetricsId of(Long productId, MetricsType metricsType) {
     String nowDate = convertDate(Instant.now());
     return new ProductMetricsId(productId, nowDate, metricsType);
 }

+public static ProductMetricsId of(Long productId, String metricsDate, MetricsType metricsType) {
+    return new ProductMetricsId(productId, metricsDate, metricsType);
+}

44-49: ProductMetricsModel과 동일하게 ZoneId.systemDefault() 대신 UTC 사용을 권장합니다.

분산 환경에서의 일관성을 위해 동일한 시간대 정책을 적용하는 것이 좋습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (1)

16-16: 레포지토리 변수명이 실제 역할과 맞지 않습니다.

ProductMetricsRepository는 이제 모든 메트릭 타입(VIEW, LIKE, ORDER_SUCCESS 등)을 처리하지만, 변수명이 likeMetricsRepository로 되어 있어 혼란을 줄 수 있습니다.

🔎 변수명 수정 제안
-    private final ProductMetricsRepository likeMetricsRepository;
+    private final ProductMetricsRepository metricsRepository;

그리고 메서드 내에서도 해당 변수 사용을 업데이트해 주세요.

apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java (1)

81-87: 카운트 조회 로직이 올바르며, 멱등성 검증이 적절합니다.

새로운 모델의 getCount() 사용이 올바르고, 로깅 추가가 디버깅에 유용합니다. 멱등성 검증 로직(중복 메시지 전송 후 카운트 +1, 처리 기록 1건)이 정확합니다.

선택적 개선: Line 86의 "product-like-metrics" 문자열을 상수로 추출하면 유지보수성이 향상됩니다.

🔎 선택적 리팩토링: 컨슈머 이름 상수화
+    private static final String CONSUMER_NAME = "product-like-metrics";
+
     @Test
     @DisplayName("멱등성 테스트")
     void duplicate_message_should_be_applied_once() throws Exception {
         // ... existing code ...
         
-        long handledCount = handledRepo.countByConsumerNameAndEventId("product-like-metrics", "06b9f00c-04bb-40fb-a96c-a9da5d0ede53");
+        long handledCount = handledRepo.countByConsumerNameAndEventId(CONSUMER_NAME, "06b9f00c-04bb-40fb-a96c-a9da5d0ede53");
         assertThat(handledCount).isEqualTo(1);
     }
apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java (2)

19-23: 타임존 설정 시점을 main 메서드로 이동 권장

TimeZone.setDefault()는 JVM 전역 설정이므로 가능한 한 빨리 설정되어야 합니다. @PostConstruct에서 설정하면 다른 빈들이 초기화될 때 이미 날짜/시간 관련 값들이 생성될 수 있어 일관성 문제가 발생할 수 있습니다.

🔎 권장 수정사항
-    @PostConstruct
-    public void started() {
-        // set timezone
-        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
-    }
-
     public static void main(String[] args) {
+        // set timezone before Spring context initialization
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
         int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args));
         System.exit(exitCode);
     }

또는 JVM 인자로 설정하는 방법도 고려할 수 있습니다:

-Duser.timezone=Asia/Seoul

30-41: DataSource 명시적 설정 방식 검토 권장

현재 BeanFactoryPostProcessor를 통해 DataSource 별칭을 등록하는 것은 Spring Batch가 "dataSource"라는 이름의 빈을 기대하기 때문인 것으로 보입니다. 이 방식은 동작하지만 우회적인 접근입니다.

다음과 같은 대안을 검토해보세요:

  1. application.yml에서 spring.batch.jdbc.data-source로 명시적으로 DataSource 빈 이름 지정
  2. BatchConfigurer를 커스터마이징하여 DataSource를 명시적으로 설정

현재 구현은 방어적이고 안전하지만, 설정 기반 접근이 더 명확할 수 있습니다.

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

14-14: QueryDSL 의존성의 버전 관리는 정상이나, 명시적 선언 추천

querydsl-apt::jakarta 구문은 Spring Boot의 암시적 의존성 관리를 통해 버전이 관리되고 있습니다. 프로젝트에서 Spring Boot 3.4.4를 사용 중이며, io.spring.dependency-management 플러그인이 QueryDSL 버전을 자동으로 관리하고 있으므로 현재 구성은 동작합니다.

다만 다음 개선을 권장합니다:

  1. jakarta classifier는 올바릅니다 - Spring Boot 3.x는 Jakarta EE를 표준으로 사용하므로 현재 설정이 맞습니다.
  2. 버전 관리를 명시적으로 선언하는 것이 권장됩니다 - 루트 build.gradle.kts에서 Spring Cloud BOM만 import하고 있으므로, 더 명확하게 하려면 다음과 같이 Spring Boot BOM을 명시적으로 import하는 것을 고려하세요:
    dependencyManagement {
        imports {
            mavenBom("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")
        }
    }

현재 구성이 정상 작동하지만, 의존성 관리의 출처를 명시적으로 선언하면 코드 가독성과 유지보수성이 향상됩니다.

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

40-47: Step 메서드명과 실제 용도가 일치하지 않습니다.

categorySyncStep() 메서드명은 카테고리 동기화를 암시하지만, 실제로는 demoSimpleTask라는 이름의 데모용 Step입니다. 혼동을 방지하기 위해 메서드명을 Step의 실제 용도에 맞게 변경하는 것이 좋습니다.

🔎 제안된 수정
     @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();
     }

그리고 demoJob() 메서드에서도 참조를 업데이트:

     @Bean(JOB_NAME)
     public Job demoJob() {
         return new JobBuilder(JOB_NAME, jobRepository)
                 .incrementer(new RunIdIncrementer())
-                .start(categorySyncStep())
+                .start(demoSimpleTaskStep())
                 .listener(jobListener)
                 .build();
     }
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (2)

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

RuntimeException 대신 IllegalArgumentException이나 커스텀 배치 예외를 사용하면 예외의 원인을 더 명확하게 표현할 수 있습니다.

🔎 제안된 수정
         if (requestDate == null) {
-            throw new RuntimeException("requestDate is null");
+            throw new IllegalArgumentException("Required job parameter 'requestDate' is null");
         }

27-29: System.out.println 대신 SLF4J 로거 사용을 권장합니다.

프로덕션 배치 환경에서는 로그 레벨 제어, 로그 집계, 구조화된 로깅 등을 위해 적절한 로깅 프레임워크를 사용하는 것이 좋습니다. 데모 목적이라도 일관된 로깅 패턴을 유지하면 실제 Job 구현 시 참고하기 좋습니다.

🔎 제안된 수정
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @StepScope
 @ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME)
 @RequiredArgsConstructor
 @Component
 public class DemoTasklet implements Tasklet {
     @Value("#{jobParameters['requestDate']}")
     private String requestDate;

     @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;
     }
 }
apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (3)

4-4: 사용되지 않는 @RequiredArgsConstructor 어노테이션을 제거하세요.

이 클래스에는 final 필드가 없고 모든 의존성이 @Autowired로 주입되므로 @RequiredArgsConstructor가 불필요합니다.

🔎 제안된 수정
 import com.loopers.batch.job.demo.DemoJobConfig;
-import lombok.RequiredArgsConstructor;
 import org.junit.jupiter.api.BeforeEach;

그리고 클래스 선언에서:

 @SpringBootTest
 @SpringBatchTest
 @TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME)
-@RequiredArgsConstructor
 class DemoJobE2ETest {

37-40: 비어 있는 @BeforeEach 메서드를 제거하세요.

아무런 설정을 수행하지 않는 빈 메서드는 코드 가독성을 저해합니다.

🔎 제안된 수정
-    @BeforeEach
-    void beforeEach() {
-
-    }
-
     @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.")

42-56: 테스트 메서드명이 실제 테스트 동작과 일치하지 않습니다.

shouldNotSaveCategories_whenApiError는 카테고리 저장과 API 에러를 암시하지만, 실제로는 requestDate 파라미터가 없을 때 Job이 실패하는지 검증합니다. @DisplayName의 내용과 일치하도록 메서드명을 수정하는 것이 좋습니다.

🔎 제안된 수정
     @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.")
     @Test
-    void shouldNotSaveCategories_whenApiError() throws Exception {
+    void shouldFail_whenRequestDateParameterIsMissing() throws Exception {
         // arrange
         jobLauncherTestUtils.setJob(job);
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java (1)

3-9: LGTM - productId 타입 일관성 고려 제안

레코드 구조가 명확하고 주석도 적절합니다.

다만 선택적으로 productIdLong 대신 long으로 변경하여 count 필드들과 타입 일관성을 맞추고 null 안전성을 높이는 것을 고려해볼 수 있습니다. 배치 처리 컨텍스트에서 productId가 null일 가능성은 없으므로, primitive 타입 사용이 더 적합할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java (1)

10-16: LGTM - job parameter 일관성 확인 제안

Spring Batch의 step-scoped bean 설정이 올바르게 구현되었습니다. yearMonth 파라미터를 통해 processor에 job 컨텍스트를 주입하는 패턴이 적절합니다.

선택적으로 RankMonthlyJobConfig에서 이 job parameter들(yearMonth, startDate, endDate)이 모두 일관되게 전달되는지 확인하는 것을 권장합니다.

#!/bin/bash
# RankMonthlyJobConfig에서 job parameter 설정 확인
ast-grep --pattern 'class RankMonthlyJobConfig {
  $$$
}'
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java (1)

5-10: 입력 검증 로직 추가를 고려하세요.

RankingQuery는 데이터 전송 객체로 적절하지만, 필드에 대한 검증이 없습니다:

  • period, key, date는 null이 될 수 있습니다
  • limit은 음수나 0이 될 수 있습니다

RankingFetchStrategyResolver.resolve()에서 일부 정규화가 이루어지는 것으로 보이지만, 이 record가 다른 곳에서 직접 생성될 경우 유효하지 않은 쿼리가 생성될 수 있습니다. Java Bean Validation (@NotNull, @Positive 등) 또는 정적 팩토리 메서드를 통한 검증을 고려하세요.

🔎 검증 로직 추가 예시

정적 팩토리 메서드를 사용한 검증:

+import java.util.Objects;
+
 public record RankingQuery(
         RankingPeriod period,
         String key,
         LocalDate date,
         int limit
-) { }
+) {
+    public RankingQuery {
+        Objects.requireNonNull(period, "period must not be null");
+        Objects.requireNonNull(key, "key must not be null");
+        Objects.requireNonNull(date, "date must not be null");
+        if (limit <= 0) {
+            throw new IllegalArgumentException("limit must be positive");
+        }
+    }
+}
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java (1)

30-35: 점수 계산 로직이 PR 명세와 일치합니다.

계산 공식 (0.1view + 0.2like + 0.7*order)이 올바르게 구현되어 있습니다. TODO 코멘트에 언급된 정책 분리는 향후 개선사항으로 적절합니다.

TODO 항목인 점수 계산 정책 분리를 위한 전략 패턴 구현 코드를 생성하거나, 별도 이슈로 생성하시겠습니까?

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

31-38: 로그 레벨을 error로 변경 권장

실패 예외 정보를 로깅할 때 log.info 대신 log.error를 사용하는 것이 적절합니다. 모니터링 시스템에서 에러 로그를 필터링하고 알림을 설정하는 데 도움이 됩니다.

🔎 로그 레벨 변경 제안
-            log.info(
+            log.error(
                 """
                    [에러 발생]
                    jobName: {}
                    exceptions:
                    {}
                """.trim(), jobName, exceptions
             );

13-16: 불필요한 @RequiredArgsConstructor 어노테이션

현재 클래스에 final 필드가 없으므로 @RequiredArgsConstructor는 불필요합니다. 제거해도 동작에 영향이 없습니다.

apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java (1)

14-21: year_month_key 컬럼 길이 조정 검토

yearMonth 필드는 배치 Writer와 Repository에서 일관되게 YYYYMM 형식(6자리, 예: "202601")으로 사용되고 있습니다. 현재 선언된 컬럼 길이 length = 8은 실제 형식보다 약간 크므로, 정확성을 위해 length = 6으로 조정하는 것을 권장합니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java (1)

45-74: 서브쿼리 집계 쿼리 구조 확인 완료 - 인덱스 최적화 권장

서브쿼리 기반 집계 쿼리가 올바르게 구성되었습니다. 다만, product_metrics 테이블의 데이터가 증가할 경우 쿼리 성능 최적화를 위해 (metrics_date, product_id, metrics_type) 복합 인덱스 생성을 권장합니다. 현재 쿼리는 WHERE pm.metrics_date between :startDate and :endDate로 metrics_date를 필터링하고 GROUP BY pm.product_id로 집계하므로, 이 인덱스는 쿼리 실행 계획을 개선할 수 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java (1)

23-28: 점수 계산 로직을 전략 패턴 또는 설정 기반으로 분리하는 것을 고려하세요.

TODO 주석에서 언급한 대로, 가중치가 하드코딩되어 있어 정책 변경 시 코드 수정이 필요합니다. 다음 접근 방식을 고려해보세요:

  • Option 1: ScoreCalculationStrategy 인터페이스를 도입하고 가중치를 외부화
  • Option 2: application.yml에 가중치를 설정하고 @ConfigurationProperties로 주입
🔎 전략 패턴 기반 리팩토링 예시
// ScoreCalculationStrategy 인터페이스 생성
public interface ScoreCalculationStrategy {
    double calculate(long viewCount, long likeCount, long orderCount);
}

// 설정 기반 구현
@Component
@ConfigurationProperties(prefix = "ranking.score.weights")
public class WeightedScoreCalculator implements ScoreCalculationStrategy {
    private double view = 0.1;
    private double like = 0.2;
    private double order = 0.7;
    
    @Override
    public double calculate(long viewCount, long likeCount, long orderCount) {
        return view * viewCount + like * likeCount + order * orderCount;
    }
    
    // getters and setters
}

// Processor에서 사용
@Component
@RequiredArgsConstructor
public class RankWeeklyProcessor implements ItemProcessor<WeeklyAggRow, WeeklyRankRow> {
    private final ScoreCalculationStrategy scoreCalculator;
    
    private double calculateScore(WeeklyAggRow item) {
        return scoreCalculator.calculate(item.viewCount(), item.likeCount(), item.orderCount());
    }
}
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java (1)

46-47: 타임존 처리의 일관성을 고려하세요.

ProductRankWeekly.java에서는 LocalDateTime.now(ZoneId.systemDefault())을 사용하지만, 여기서는 LocalDateTime.now()를 사용하고 있습니다. 동일한 패턴을 사용하는 것이 일관성 측면에서 좋습니다.

🔎 일관성을 위한 수정
-    e.createdAt = LocalDateTime.now();
-    e.updatedAt = LocalDateTime.now();
+    e.createdAt = LocalDateTime.now(ZoneId.systemDefault());
+    e.updatedAt = LocalDateTime.now(ZoneId.systemDefault());

overwrite() 메서드의 55라인도 동일하게 수정하면 좋습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java (1)

36-36: assertUpdates(false) 설정이 업데이트 실패를 감지하지 못하게 합니다.

assertUpdates(false)로 설정하면 실제로 행이 업데이트되지 않아도 배치가 실패하지 않습니다. Upsert 작업의 특성상 의도적일 수 있지만, 실제 오류 상황도 무시될 수 있습니다.

업데이트 실패를 모니터링할 수 있는 다른 방법(로깅, 메트릭 등)이 있는지 확인하세요.

apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java (1)

46-47: 타임스탬프 생성 방식이 일관되지 않습니다.

Line 46-47에서는 LocalDateTime.now(ZoneId.systemDefault())를 사용하지만, Line 55에서는 LocalDateTime.now()를 사용합니다. LocalDateTime.now()는 기본적으로 시스템 기본 ZoneId를 사용하므로 동작은 동일하지만, 코드 일관성을 위해 동일한 방식을 사용하는 것이 좋습니다.

참고로 apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java에서는 모든 타임스탬프 생성에 LocalDateTime.now()를 사용하고 있습니다.

🔎 일관성 개선 제안

옵션 1: ZoneId 명시 제거 (ProductRankMonthly와 일관성 유지)

-    e.createdAt = LocalDateTime.now(ZoneId.systemDefault());
-    e.updatedAt = LocalDateTime.now(ZoneId.systemDefault());
+    e.createdAt = LocalDateTime.now();
+    e.updatedAt = LocalDateTime.now();

옵션 2: ZoneId 명시 추가

-    this.updatedAt = LocalDateTime.now();
+    this.updatedAt = LocalDateTime.now(ZoneId.systemDefault());

Also applies to: 55-55

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

62-77: 코드 중복 및 메서드 네이밍 개선이 필요합니다.

initLocalDatehasValidDate 메서드가 apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java (lines 19-26)에도 동일하게 구현되어 있습니다.

또한 hasValidDate 메서드명이 혼란스럽습니다. 이 메서드는 date가 null이거나 blank일 때 true를 반환하는데, "유효한 날짜가 있는가?"라는 의미와 반대로 동작합니다.

🔎 개선 제안

1. 공통 유틸리티 클래스로 추출:

새로운 유틸리티 클래스 생성:

public class RankingDateUtils {
    private static final DateTimeFormatter BASIC_ISO_DATE = DateTimeFormatter.BASIC_ISO_DATE;
    
    public static LocalDate parseOrToday(String date) {
        return (date == null || date.isBlank())
                ? LocalDate.now(ZoneId.systemDefault())
                : LocalDate.parse(date, BASIC_ISO_DATE);
    }
}

이후 RankingFetchStrategyResolver와 DailyRankingFetcher에서 이 유틸리티를 사용하도록 변경:

-private LocalDate initLocalDate(String date) {
-    return (hasValidDate(date))
-            ? LocalDate.now(ZoneId.systemDefault())
-            : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
-}
-
-private boolean hasValidDate(String date) {
-    return date == null || date.isBlank();
-}
+private LocalDate initLocalDate(String date) {
+    return RankingDateUtils.parseOrToday(date);
+}

2. 또는 메서드명 개선 (최소한의 변경):

-private boolean hasValidDate(String date) {
+private boolean isDateEmpty(String date) {
     return date == null || date.isBlank();
 }
 
 private LocalDate initLocalDate(String date) {
-    return (hasValidDate(date))
+    return (isDateEmpty(date))
             ? LocalDate.now(ZoneId.systemDefault())
             : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
 }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9f582 and ca9b019.

📒 Files selected for processing (51)
  • README.md
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQuery.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategy.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.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/infrastructure/db/ProductMetricDataInitializer.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/rankMonthly/RankMonthlyJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyAggRow.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.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/resources/application.yml
  • apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java
  • apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java
  • settings.gradle.kts
💤 Files with no reviewable changes (2)
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductLikeMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductLikeMetricsModel.java
🧰 Additional context used
🧠 Learnings (10)
📚 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-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsModel.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-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ProductMetricsId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java
📚 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-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankWeeklyRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/ProductRankMonthlyRepository.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
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.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-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java
  • apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.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/StepMonitorListener.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
📚 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/StepMonitorListener.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java
📚 Learning: 2025-12-19T20:59:57.713Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: docs/week8/round8-detailed-design.md:151-178
Timestamp: 2025-12-19T20:59:57.713Z
Learning: In the Loopers-dev-lab/loopers-spring-kotlin-template repository's Kafka event pipeline, only 5 domain events are intentionally published to Kafka via CloudEventEnvelopeFactory: OrderPaidEventV1, LikeCreatedEventV1, LikeCanceledEventV1, ProductViewedEventV1, and StockDepletedEventV1. Other domain events (OrderCreatedEventV1, OrderCanceledEventV1, PaymentCreatedEventV1, PaymentPaidEventV1, PaymentFailedEventV1) are internal-only and intentionally not mapped in resolveMetadata(), which correctly returns null for them to exclude them from Outbox publication.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java
📚 Learning: 2025-12-19T23:39:20.851Z
Learnt from: toongri
Repo: Loopers-dev-lab/loopers-spring-kotlin-template PR: 68
File: apps/commerce-streamer/src/main/kotlin/com/loopers/interfaces/consumer/product/ProductEventConsumer.kt:0-0
Timestamp: 2025-12-19T23:39:20.851Z
Learning: In Loopers-dev-lab/loopers-spring-kotlin-template, toongri prefers pragmatic idempotency handling in Kafka consumers: when idempotency key persistence fails after successful business logic execution, log a warning and continue (accepting low risk of duplicates on retry) rather than rolling back business data. This keeps business logic decoupled from idempotency store (which might be Redis/external system, not RDB), avoids over-engineering for rare edge cases, and prioritizes core business value over auxiliary metadata. Transaction-based atomicity between business data and idempotency keys is considered excessive when duplicate event probability is very low.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java
📚 Learning: 2025-12-19T03:23:54.756Z
Learnt from: minor7295
Repo: Loopers-dev-lab/loopers-spring-java-template PR: 191
File: modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java:24-34
Timestamp: 2025-12-19T03:23:54.756Z
Learning: In the loopers-spring-java-template project, test container configurations (MySQL, Redis, Kafka) use static initialization blocks for consistency across the template. This pattern should be maintained for uniformity, even if alternative lifecycle management approaches exist.

Applied to files:

  • apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java
  • apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java
🧬 Code graph analysis (17)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java (1)
  • Entity (12-65)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java (1)
  • Configuration (12-39)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java (1)
  • Configuration (16-80)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java (1)
apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java (1)
  • ConditionalOnProperty (19-48)
apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.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/JobListener.java (1)
  • Slf4j (14-53)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java (2)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java (1)
  • Configuration (8-17)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java (1)
  • Configuration (16-77)
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java (1)
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java (1)
  • Component (14-39)
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-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java (3)
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/MonthlyRankingFetcher.java (1)
  • Slf4j (13-29)
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java (1)
  • Component (14-39)
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/RankingFetchStrategyResolver.java (1)
  • Component (16-78)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java (1)
  • Entity (13-65)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java (2)
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/job/rankWeekly/step/RankWeeklyWriterConfig.java (1)
  • Configuration (12-39)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java (1)
  • Embeddable (9-21)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyReaderConfig.java (3)
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/job/rankMonthly/RankMonthlyProcessorConfig.java (1)
  • Configuration (8-17)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java (1)
  • Configuration (12-39)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyProcessorConfig.java (3)
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/job/rankMonthly/RankMonthlyReaderConfig.java (1)
  • Configuration (16-77)
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/RankMonthlyWriterConfig.java (1)
  • Configuration (12-39)
apps/commerce-batch/src/main/java/com/loopers/batch/infrastructure/db/ProductMetricDataInitializer.java (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/kafka/config/KafkaTopicsConfig.java (1)
  • Profile (9-37)
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/test/java/com/loopers/CommerceBatchApplicationTest.java (1)
apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)
  • SpringBootTest (23-76)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java (1)
apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthlyId.java (1)
  • Embeddable (9-21)
🔇 Additional comments (36)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java (1)

9-9: 변경 사항 승인!

UserActionType import 추가가 적절합니다. 이 import는 기존 @TrackUserAction 어노테이션(28번, 42번 라인)에서 사용되고 있으며, 코드의 명시성을 개선합니다.

apps/commerce-batch/src/main/resources/application.yml (2)

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

web-application-type: nonejob.name: ${job.name:NONE} 설정이 배치 애플리케이션에 적합하며, 기본값 NONE을 통해 의도하지 않은 작업 실행을 방지하는 안전 장치가 잘 구현되어 있습니다.


17-18: Spring Batch 메타데이터 테이블 스키마 관리 전략을 명확히 하세요.

local/test 프로파일은 initialize-schema: always로 설정되지만, dev/qa/prd 프로파일은 기본값 never를 상속합니다. 현재 프로젝트에서 Flyway나 Liquibase 같은 스키마 마이그레이션 도구가 구성되지 않았기 때문에, 운영 환경에서 Spring Batch 메타데이터 테이블(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION 등)이 어떻게 생성되고 관리되는지 명시해야 합니다.

해당 테이블이 미리 생성되어 있거나, 향후 스키마 마이그레이션 도구 도입 계획이 있다면 코드나 설정 파일에 명시하세요.

⛔ Skipped due to learnings
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.
apps/commerce-batch/src/main/resources/db/fixtures/large-product-metric-data.sql (1)

5-44: Fixture 데이터 생성 로직이 적절합니다.

Cross join을 활용한 벌크 데이터 생성 패턴이 효율적이며, metrics_type별로 현실적인 count 범위를 설정한 것이 좋습니다. Local 프로파일에서만 실행되므로 운영 환경에 영향을 주지 않습니다.

apps/commerce-streamer/src/main/java/com/loopers/domain/MetricsType.java (1)

1-8: LGTM!

enum 상수들이 SQL fixture 및 도메인 요구사항과 일치하며, 확장 가능한 구조입니다.

apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java (1)

1-8: LGTM!

복합 키(ProductMetricsId)를 사용하는 표준 Spring Data JPA 레포지토리입니다. 필요시 커스텀 쿼리 메서드를 추가할 수 있습니다.

apps/commerce-streamer/src/main/java/com/loopers/application/metrics/MetricsAggregationService.java (1)

18-28: 복합 키 기반 메트릭 처리 로직이 적절합니다.

findById().orElseGet() 패턴으로 조회 또는 생성 후 increase()를 호출하는 방식이 JPA dirty checking과 함께 정상 동작합니다. @Transactional 내에서 엔티티 변경이 트랜잭션 종료 시 자동으로 flush됩니다.

apps/commerce-streamer/src/test/java/com/loopers/application/idempotency/EventHandledServiceTest.java (4)

3-7: 새로운 ProductMetrics 도메인 모델로의 마이그레이션이 올바르게 반영되었습니다.

임포트 및 로깅 어노테이션 추가가 적절합니다. MetricsType, ProductMetricsId, ProductMetricsModel로의 전환이 PR 목표와 일치합니다.

Also applies to: 10-10, 26-26


38-38: 리포지토리 타입 업데이트가 올바릅니다.

ProductMetricsRepository로의 전환이 통합된 메트릭 모델과 일치합니다.


68-73: 비동기 처리 대기를 위한 Thread.sleep 사용에 대한 TODO가 있습니다.

Thread.sleep을 사용한 동기화는 테스트 안정성 측면에서 취약할 수 있으나, 이미 TODO 주석(line 72)으로 개선 필요성이 문서화되어 있습니다. 향후 Awaitility 라이브러리나 TestContainers의 컨슈머 동기화 메커니즘을 고려해보시기 바랍니다.


57-62: 복합 키 패턴이 올바르게 적용되었습니다.

ProductMetricsId에 MetricsType.LIKE를 사용한 것이 적절하며, 로깅 추가가 테스트 디버깅에 유용합니다. ProductMetricsId.of(productId, metricsType) 팩토리 메서드가 자동으로 metricsDate를 현재 날짜의 yyyyMMdd 형식으로 초기화하므로, 복합 키의 세 필드(productId, metricsDate, metricsType) 모두가 올바르게 설정됩니다. 데이터베이스의 NOT NULL 제약 조건도 만족합니다.

README.md (1)

30-30: LGTM! 문서 업데이트가 적절합니다.

새로운 commerce-batch 모듈이 프로젝트 트리에 올바르게 추가되었습니다.

settings.gradle.kts (1)

6-6: LGTM! 모듈 추가가 적절합니다.

commerce-batch 모듈이 Gradle 멀티모듈 프로젝트에 올바르게 등록되었습니다.

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

1-21: 의존성 구성이 전반적으로 적절합니다.

필요한 모듈들(jpa, redis, jackson, logging, monitoring)과 Spring Batch 의존성이 올바르게 구성되어 있습니다. test-fixtures 사용도 적절합니다.

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

6-9: LGTM! 표준 스모크 테스트 패턴입니다.

Spring Boot 애플리케이션 컨텍스트가 정상적으로 로드되는지 확인하는 기본 테스트입니다. 빈 테스트 본문은 의도된 것으로, 설정 오류를 조기에 발견하는 데 유용합니다.

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

25-28: LGTM! 배치 애플리케이션에 적합한 종료 패턴입니다.

SpringApplication.exit()System.exit()를 사용하여 배치 작업 완료 후 적절하게 종료 코드를 전파하고 있습니다.

apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java (1)

58-75: 테스트 구조와 검증 로직이 적절합니다.

성공 케이스에서 JobParametersBuilder를 사용하여 requestDate를 설정하고, ExitStatus.COMPLETED를 검증하는 방식이 잘 구현되어 있습니다.

apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeeklyId.java (1)

9-14: LGTM!

JPA 임베디드 ID 클래스 구조가 올바르게 구현되었습니다. Serializable 구현, equals/hashCode 오버라이드, 그리고 protected 기본 생성자가 모두 적절하게 포함되어 있습니다.

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

3-7: LGTM!

전략 패턴에서 사용되는 기간 타입을 명확하게 정의하는 간결한 enum입니다. 타입 안전성을 제공하며 향후 확장이 용이한 구조입니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyRankRow.java (2)

3-10: 간결하고 명확한 DTO 설계입니다.

Record를 사용하여 불변 데이터 전송 객체를 구현한 것은 적절합니다. 필드명과 타입이 명확하며 배치 작업의 출력 모델로 적합합니다.


5-5: productId가 null이 될 수 있는 경로를 검토하세요.

Reader의 SQL 쿼리에서 GROUP BY pm.product_id를 사용하고 있습니다. 만약 원본 데이터에 null product_id가 존재하면, SQL GROUP BY는 null을 별도의 그룹으로 포함시킵니다. 현재 코드는:

  • MonthlyAggRowMonthlyRankRow 모두 Long productId (nullable)
  • Reader: rs.getLong() 사용 (nullable 컬럼에 대해 부적절할 수 있음)
  • Processor: null 체크 없이 그대로 전달
  • Writer: :productId를 SQL에 사용

데이터 무결성을 보장하려면, Reader 단계에서 null product_id 행을 필터링하거나, productId를 primitive long으로 변경하는 것을 권장합니다.

⛔ Skipped due to learnings
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() 팩토리 메서드만으로 충분합니다.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingQueryService.java (1)

28-28: 전략 패턴을 위한 적절한 의존성 추가입니다.

RankingFetchStrategyResolver 의존성을 주입하여 기간별 랭킹 조회 전략을 선택할 수 있도록 한 것은 좋은 설계입니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankMonthly/dto/MonthlyAggRow.java (2)

3-8: 간결한 입력 DTO 설계입니다.

배치 Reader의 입력 모델로 적절한 구조입니다. MonthlyRankRow와의 명확한 구분(yearMonth와 score 없음)이 Reader-Processor-Writer 파이프라인의 책임 분리를 잘 나타냅니다.


4-4: productId는 NULL 값을 가질 수 없으므로 추가 조치가 불필요합니다.

SQL 쿼리의 GROUP BY pm.product_id 절 때문에 NULL productId는 결과 집합에 포함되지 않습니다. GROUP BY는 NULL 값을 필터링하므로 Reader가 반환하는 모든 productId는 NULL이 될 수 없습니다. 따라서 현재 코드는 NULL 처리 관점에서 안전합니다.

다만, 향후 쿼리 구조가 변경되거나 LEFT JOIN 등이 추가될 경우를 대비해 rs.getLong() 대신 rs.getObject("product_id", Long.class) 사용을 고려하거나, productId를 primitive long으로 변경하는 것을 권장합니다.

apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/WeeklyRankingFetcher.java (2)

13-29: 전략 패턴 구현이 일관되고 명확합니다.

WeeklyRankingFetcherRankingFetchStrategy 인터페이스를 올바르게 구현했으며, DailyRankingFetcherMonthlyRankingFetcher와 일관된 패턴을 따릅니다. 코드가 간결하고 책임이 명확합니다.


24-28: 글로벌 예외 핸들러가 이미 구성되어 있습니다.

weeklyRankingRepository.findTopByYearWeek() 호출에서 발생하는 예외는 ApiControllerAdvice의 일반적인 Throwable 핸들러(line 118-122)에 의해 자동으로 처리됩니다. 데이터베이스 오류나 쿼리 오류는 ErrorType.INTERNAL_ERROR로 응답됩니다.

현재 구조는 일관성 있게 설계되었으나, 다음을 고려하세요:

  • 저장소 오류에 대한 특정 에러 응답이 필요하면 CoreException을 명시적으로 던지거나 ApiControllerAdvice에 별도 핸들러 추가
  • yearWeek 형식 검증이 필요하면 쿼리 전에 유효성 검사 추가
  • 현재는 다른 fetcher와 동일한 패턴이므로 일관성은 유지됨
apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java (1)

25-52: 시간 계산 및 로깅 로직이 정확합니다.

Duration을 사용한 시간 계산과 포맷팅이 올바르게 구현되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/dto/WeeklyRankRow.java (1)

1-10: Record 정의가 적절합니다.

배치 처리에 사용되는 DTO로서 불변 record 타입이 적합하게 선택되었고, 필드 타입도 올바르게 정의되어 있습니다.

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

1-12: 전략 패턴 인터페이스가 잘 설계되었습니다.

period별 ranking 조회 전략을 추상화한 인터페이스 설계가 명확하고 확장 가능합니다.

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

13-25: LGTM!

리포지토리 인터페이스와 JPQL 쿼리가 올바르게 구현되었습니다. RankingEntry 생성자 표현식과 Pageable을 활용한 페이징 처리가 적절합니다.

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

13-25: LGTM!

ProductRankWeeklyRepository와 일관된 패턴으로 구현되었습니다. 월간 순위 조회를 위한 쿼리가 적절합니다.

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

13-29: LGTM!

전략 패턴을 활용한 깔끔한 구현입니다. WeeklyRankingFetcher와 일관된 패턴을 따르고 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyProcessor.java (1)

8-21: 구현이 올바릅니다.

배치 프로세서가 WeeklyAggRow를 WeeklyRankRow로 정확하게 변환하고 있으며, 점수 계산 로직도 통합되어 있습니다.

apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java (1)

12-37: 엔티티 구조가 올바릅니다.

복합 키와 필드 매핑이 적절하게 구성되어 있습니다.

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

14-28: 전략 패턴 구현이 올바릅니다.

RankingFetchStrategy 인터페이스를 올바르게 구현하고 있으며, 일일 순위 조회 로직이 적절하게 캡슐화되어 있습니다.

apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/RankWeeklyJobConfig.java (1)

23-56: Spring Batch 구성이 올바르게 작성되었습니다.

chunk 크기 1000으로 설정된 표준적인 chunk-oriented step 구성이며, Reader/Processor/Writer가 적절히 연결되어 있습니다. Job과 Step 수준의 리스너도 올바르게 설정되었습니다.

Comment on lines +30 to +38
private LocalDate initLocalDate(String date) {
return (hasValidDate(date))
? LocalDate.now(ZoneId.systemDefault())
: LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
}

private boolean hasValidDate(String date) {
return date == null || date.isBlank();
}
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

메서드 이름이 실제 동작과 반대입니다.

hasValidDate() 메서드는 date가 null이거나 blank일 때 true를 반환합니다. 이는 "유효한 날짜가 있는지"를 확인하는 것이 아니라 "날짜가 없는지"를 확인하는 것이므로, 메서드 이름이 로직과 일치하지 않아 혼란을 야기합니다.

또한 날짜 파싱 시 DateTimeParseException에 대한 예외 처리가 없어, 잘못된 형식의 날짜가 전달될 경우 적절한 에러 응답 없이 예외가 발생합니다.

🔎 제안하는 개선사항
 private LocalDate initLocalDate(String date) {
-    return (hasValidDate(date))
+    return (isDateMissing(date))
             ? LocalDate.now(ZoneId.systemDefault())
-            : LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
+            : parseDate(date);
 }

-private boolean hasValidDate(String date) {
+private boolean isDateMissing(String date) {
     return date == null || date.isBlank();
 }

+private LocalDate parseDate(String date) {
+    try {
+        return LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
+    } catch (DateTimeParseException e) {
+        throw new IllegalArgumentException(
+            "Invalid date format. Expected format: yyyyMMdd, but got: " + date, e);
+    }
+}
📝 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 LocalDate initLocalDate(String date) {
return (hasValidDate(date))
? LocalDate.now(ZoneId.systemDefault())
: LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
}
private boolean hasValidDate(String date) {
return date == null || date.isBlank();
}
private LocalDate initLocalDate(String date) {
return (isDateMissing(date))
? LocalDate.now(ZoneId.systemDefault())
: parseDate(date);
}
private boolean isDateMissing(String date) {
return date == null || date.isBlank();
}
private LocalDate parseDate(String date) {
try {
return LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
"Invalid date format. Expected format: yyyyMMdd, but got: " + date, e);
}
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/strategy/DailyRankingFetcher.java
around lines 30-38, the helper method name and logic are inverted and there is
no parsing error handling: rename or flip hasValidDate so its name matches its
behavior (e.g., isBlankOrNull -> retains current boolean) or change its logic to
return true when the input is a valid non-blank date, then update initLocalDate
to use the corrected predicate; additionally wrap LocalDate.parse in a try/catch
for DateTimeParseException and handle it by throwing a controlled application
exception or returning a clear error value (or fallback) with a descriptive
message so malformed dates do not propagate raw exceptions.

Comment on lines +39 to +49
public static ProductRankMonthly of(String yearMonth, Long productId,
long viewCount, long likeCount, long orderCount) {
ProductRankMonthly e = new ProductRankMonthly();
e.id = new ProductRankMonthlyId(yearMonth, productId);
e.viewCount = viewCount;
e.likeCount = likeCount;
e.orderCount = orderCount;
e.createdAt = LocalDateTime.now();
e.updatedAt = LocalDateTime.now();
return e;
}
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

치명적: score 필드가 초기화되지 않습니다.

score 필드는 nullable = false로 선언되어 있지만 of() 메서드에서 초기화되지 않습니다. 이는 엔티티 저장 시 제약조건 위반(constraint violation)을 발생시킵니다.

배치 작업의 RankMonthlyProcessor에서 점수를 계산하므로, 팩토리 메서드가 score 매개변수를 받아야 합니다.

🔎 제안하는 수정사항
 public static ProductRankMonthly of(String yearMonth, Long productId,
-                                    long viewCount, long likeCount, long orderCount) {
+                                    long viewCount, long likeCount, long orderCount, double score) {
     ProductRankMonthly e = new ProductRankMonthly();
     e.id = new ProductRankMonthlyId(yearMonth, productId);
     e.viewCount = viewCount;
     e.likeCount = likeCount;
     e.orderCount = orderCount;
+    e.score = score;
     e.createdAt = LocalDateTime.now();
     e.updatedAt = LocalDateTime.now();
     return e;
 }

동일한 문제가 ProductRankWeekly.java에도 존재하므로 함께 수정이 필요합니다.

📝 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 ProductRankMonthly of(String yearMonth, Long productId,
long viewCount, long likeCount, long orderCount) {
ProductRankMonthly e = new ProductRankMonthly();
e.id = new ProductRankMonthlyId(yearMonth, productId);
e.viewCount = viewCount;
e.likeCount = likeCount;
e.orderCount = orderCount;
e.createdAt = LocalDateTime.now();
e.updatedAt = LocalDateTime.now();
return e;
}
public static ProductRankMonthly of(String yearMonth, Long productId,
long viewCount, long likeCount, long orderCount, double score) {
ProductRankMonthly e = new ProductRankMonthly();
e.id = new ProductRankMonthlyId(yearMonth, productId);
e.viewCount = viewCount;
e.likeCount = likeCount;
e.orderCount = orderCount;
e.score = score;
e.createdAt = LocalDateTime.now();
e.updatedAt = LocalDateTime.now();
return e;
}
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java
around lines 39-49, the factory method of(...) fails to initialize the
non-nullable score field causing constraint violations; update the method
signature to accept a score parameter (e.g., double or appropriate numeric
type), set e.score = score before returning, and ensure callers (including
RankMonthlyProcessor batch code) pass the computed score; apply the identical
change to ProductRankWeekly to keep both factory methods consistent.

Comment on lines +51 to +56
public void overwrite(long viewCount, long likeCount, long orderCount) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.updatedAt = LocalDateTime.now();
}
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

치명적: score 필드가 업데이트되지 않습니다.

overwrite() 메서드에서 카운트 필드들을 업데이트하지만 score 필드는 업데이트하지 않습니다. 카운트가 변경되면 점수도 재계산되어야 하므로, score 매개변수를 추가해야 합니다.

🔎 제안하는 수정사항
-public void overwrite(long viewCount, long likeCount, long orderCount) {
+public void overwrite(long viewCount, long likeCount, long orderCount, double score) {
     this.viewCount = viewCount;
     this.likeCount = likeCount;
     this.orderCount = orderCount;
+    this.score = score;
     this.updatedAt = LocalDateTime.now();
 }

동일한 문제가 ProductRankWeekly.javaoverwrite() 메서드에도 존재합니다.

📝 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 void overwrite(long viewCount, long likeCount, long orderCount) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.updatedAt = LocalDateTime.now();
}
public void overwrite(long viewCount, long likeCount, long orderCount, double score) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.score = score;
this.updatedAt = LocalDateTime.now();
}
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankMonthly.java
around lines 51–56, the overwrite(...) method updates viewCount, likeCount,
orderCount and updatedAt but does not update the score; change the method
signature to accept a score parameter (e.g., double or long matching the score
field type), assign this.score = score inside the method, and update updatedAt
as now; also search for and update all callsites to pass the recalculated score,
and apply the same change to ProductRankWeekly.java's overwrite(...) to keep
behavior consistent.

Comment on lines +39 to +49
public static ProductRankWeekly of(String yearWeek, Long productId,
long viewCount, long likeCount, long orderCount) {
ProductRankWeekly e = new ProductRankWeekly();
e.id = new ProductRankWeeklyId(yearWeek, productId);
e.viewCount = viewCount;
e.likeCount = likeCount;
e.orderCount = orderCount;
e.createdAt = LocalDateTime.now(ZoneId.systemDefault());
e.updatedAt = LocalDateTime.now(ZoneId.systemDefault());
return e;
}
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

score 필드가 초기화되지 않습니다.

factory 메서드에서 viewCount, likeCount, orderCount는 설정하지만 score 필드는 초기화되지 않아 기본값 0.0으로 남게 됩니다. PR 요약에 따르면 score = 0.1view + 0.2like + 0.7*order로 계산되어야 합니다.

🔎 score 계산 로직 추가 제안
 public static ProductRankWeekly of(String yearWeek, Long productId,
                                    long viewCount, long likeCount, long orderCount) {
     ProductRankWeekly e = new ProductRankWeekly();
     e.id = new ProductRankWeeklyId(yearWeek, productId);
     e.viewCount = viewCount;
     e.likeCount = likeCount;
     e.orderCount = orderCount;
+    e.score = 0.1 * viewCount + 0.2 * likeCount + 0.7 * orderCount;
     e.createdAt = LocalDateTime.now(ZoneId.systemDefault());
     e.updatedAt = LocalDateTime.now(ZoneId.systemDefault());
     return e;
 }
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java
around lines 39 to 49, the factory method does not set the score field so it
remains default 0.0; compute score using the formula score = 0.1 * viewCount +
0.2 * likeCount + 0.7 * orderCount (ensuring proper numeric types/casting to the
field type) and assign it to e.score before returning the instance.

Comment on lines +51 to +56
public void overwrite(long viewCount, long likeCount, long orderCount) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.updatedAt = LocalDateTime.now();
}
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

overwrite 메서드에서 score가 갱신되지 않습니다.

메트릭 카운트가 업데이트될 때 score도 함께 재계산되어야 하지만 현재는 이전 값을 유지하게 됩니다.

🔎 score 재계산 로직 추가 제안
 public void overwrite(long viewCount, long likeCount, long orderCount) {
     this.viewCount = viewCount;
     this.likeCount = likeCount;
     this.orderCount = orderCount;
+    this.score = 0.1 * viewCount + 0.2 * likeCount + 0.7 * orderCount;
     this.updatedAt = LocalDateTime.now();
 }
📝 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 void overwrite(long viewCount, long likeCount, long orderCount) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.updatedAt = LocalDateTime.now();
}
public void overwrite(long viewCount, long likeCount, long orderCount) {
this.viewCount = viewCount;
this.likeCount = likeCount;
this.orderCount = orderCount;
this.score = 0.1 * viewCount + 0.2 * likeCount + 0.7 * orderCount;
this.updatedAt = LocalDateTime.now();
}
🤖 Prompt for AI Agents
In apps/commerce-api/src/main/java/com/loopers/domain/mv/ProductRankWeekly.java
around lines 51-56, the overwrite method updates viewCount/likeCount/orderCount
and updatedAt but does not recompute score; modify the method to recalculate and
assign the score after updating the counts (e.g., call the existing score
calculation helper or compute score from the new view/like/order values and set
this.score = ...), then update updatedAt—if no helper exists, implement the same
score formula used elsewhere and assign it here.

Comment on lines +53 to +70
qp.setFromClause("""
from (
select
CONCAT(
SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 1, 4),
'-W',
LPAD(SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 5, 2), 2, '0')
) as year_week,
pm.product_id as product_id,
SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count,
SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count,
SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count
from product_metrics pm
group by year_week, pm.product_id
) t
""");

qp.setWhereClause("where t.year_week = :yearWeek");
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

성능 이슈: WHERE 절을 서브쿼리 내부로 이동 필요

현재 쿼리는 모든 데이터를 그룹화한 후 외부에서 year_week로 필터링합니다. 이는 데이터가 증가할수록 불필요한 집계 연산이 발생합니다. RankMonthlyReaderConfig처럼 서브쿼리 내부에서 날짜 범위로 필터링하는 것이 효율적입니다.

🔎 성능 개선을 위한 쿼리 수정 제안

yearWeek 파라미터 대신 해당 주의 시작일/종료일을 계산하여 서브쿼리 내부에서 필터링하거나, 아래와 같이 서브쿼리 내부에 WHERE 절 추가를 고려하세요:

         qp.setFromClause("""
             from (
               select
                 CONCAT(
                   SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 1, 4),
                   '-W',
                   LPAD(SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 5, 2), 2, '0')
                 ) as year_week,
                 pm.product_id as product_id,
                 SUM(CASE WHEN pm.metrics_type = 'VIEW' THEN pm.count ELSE 0 END) as view_count,
                 SUM(CASE WHEN pm.metrics_type = 'LIKE' THEN pm.count ELSE 0 END) as like_count,
                 SUM(CASE WHEN pm.metrics_type = 'ORDER_SUCCESS' THEN pm.count ELSE 0 END) as order_count
               from product_metrics pm
+              where CONCAT(
+                SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 1, 4),
+                '-W',
+                LPAD(SUBSTRING(YEARWEEK(STR_TO_DATE(pm.metrics_date, '%Y%m%d'), 3), 5, 2), 2, '0')
+              ) = :yearWeek
               group by year_week, pm.product_id
             ) t
             """);
-
-        qp.setWhereClause("where t.year_week = :yearWeek");

또는 월간 리더처럼 startDate, endDate 파라미터 방식으로 변경하는 것이 더 효율적입니다.

🤖 Prompt for AI Agents
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyReaderConfig.java
lines 53-70: the current query groups the entire product_metrics table then
filters by year_week in the outer WHERE, causing large unnecessary aggregation;
move the filtering into the subquery so aggregation only runs on the target
period—either add a WHERE inside the subquery that filters metrics_date
(recommended: switch the reader to accept startDate and endDate parameters and
filter metrics_date BETWEEN :startDate AND :endDate inside the subquery) or, if
keeping yearWeek param, convert it to a date range inside Java and apply
STR_TO_DATE/metrics_date comparison inside the subquery; update setFromClause to
include that WHERE and remove or adjust the outer setWhereClause accordingly.

Comment on lines +19 to +30
String sql = """
INSERT INTO mv_product_rank_weekly
(year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at)
VALUES
(:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE
view_count = VALUES(view_count),
like_count = VALUES(like_count),
order_count = VALUES(order_count),
score=VALUES(score),
updated_at = NOW(6)
""";
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

🧩 Analysis chain

🌐 Web query:

MySQL 8.0.20 VALUES function deprecated ON DUPLICATE KEY UPDATE

💡 Result:

Yes — starting in MySQL 8.0.20 the VALUES() function used inside INSERT ... ON DUPLICATE KEY UPDATE is deprecated and will be removed in a future release. Use a row (and optional column) alias for the VALUES() row instead.

  • Deprecation announced in MySQL 8.0.20 release notes (Apr 27, 2020). [1]
  • Reference manual: use VALUES(...) AS alias (or AS alias(col1,col2,...)) and then refer to alias.col in the UPDATE clause. Examples also show using a derived-subquery or SET form to avoid warnings. [2]

Example replacements

  • Old (deprecated):
    INSERT INTO t1 (a,b,c) VALUES (1,2,3) ON DUPLICATE KEY UPDATE c = VALUES(a)+VALUES(b);
  • New (use row alias):
    INSERT INTO t1 (a,b,c) VALUES (1,2,3) AS new ON DUPLICATE KEY UPDATE c = new.a + new.b;
  • Or with column aliases:
    INSERT INTO t1 (a,b,c) VALUES (1,2,3) AS new(m,n,p) ON DUPLICATE KEY UPDATE c = m + n. [2]

Sources:
[1] MySQL 8.0.20 release notes (Changes in MySQL 8.0.20).
[2] MySQL Reference Manual — INSERT ... ON DUPLICATE KEY UPDATE.


MySQL 8.0.20+ 이상에서 VALUES() 함수가 deprecated되었으므로 새로운 문법으로 수정하세요.

라인 25-28에서 사용 중인 ON DUPLICATE KEY UPDATEVALUES() 함수는 MySQL 8.0.20부터 deprecated되었으며 향후 버전에서 제거될 예정입니다. 행 별칭(row alias)을 사용하는 새로운 문법으로 업데이트해야 합니다.

🔎 수정 방법

VALUES 절 직후에 AS new_values 별칭을 추가하고, UPDATE 절에서 VALUES(column_name) 대신 new_values.column_name 형식으로 참조하세요:

         String sql = """
             INSERT INTO mv_product_rank_weekly
               (year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at)
             VALUES
               (:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6))
+            AS new_values
             ON DUPLICATE KEY UPDATE
-              view_count = VALUES(view_count),
-              like_count = VALUES(like_count),
-              order_count = VALUES(order_count),
-              score=VALUES(score),
+              view_count = new_values.view_count,
+              like_count = new_values.like_count,
+              order_count = new_values.order_count,
+              score = new_values.score,
               updated_at = NOW(6)
             """;
📝 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
String sql = """
INSERT INTO mv_product_rank_weekly
(year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at)
VALUES
(:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE
view_count = VALUES(view_count),
like_count = VALUES(like_count),
order_count = VALUES(order_count),
score=VALUES(score),
updated_at = NOW(6)
""";
String sql = """
INSERT INTO mv_product_rank_weekly
(year_week_key, product_id, view_count, like_count, order_count, score, created_at, updated_at)
VALUES
(:yearWeek, :productId, :viewCount, :likeCount, :orderCount, :score, NOW(6), NOW(6))
AS new_values
ON DUPLICATE KEY UPDATE
view_count = new_values.view_count,
like_count = new_values.like_count,
order_count = new_values.order_count,
score = new_values.score,
updated_at = NOW(6)
""";
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/job/rankWeekly/step/RankWeeklyWriterConfig.java
around lines 19 to 30, the SQL uses the deprecated VALUES() function in the ON
DUPLICATE KEY UPDATE clause; change the INSERT so the VALUES(...) row is given
an alias (e.g., AS new_values) immediately after the VALUES (...) list and then
replace each VALUES(column_name) in the UPDATE clause with
new_values.column_name for view_count, like_count, order_count and score,
keeping updated_at = NOW(6) as-is.

Comment on lines +9 to +12
@Slf4j
@RequiredArgsConstructor
@Component
public class ChunkListener {
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

Spring Batch 6.x ChunkListener 인터페이스 구현 필요

Spring Batch 6.x에서는 org.springframework.batch.core.listener.ChunkListener 인터페이스를 명시적으로 구현해야 합니다.

🔎 제안된 수정
+import org.springframework.batch.core.listener.ChunkListener;
+
 @Slf4j
 @RequiredArgsConstructor
 @Component
-public class ChunkListener {
+public class ChunkListener implements org.springframework.batch.core.listener.ChunkListener {

Based on learnings, Spring Batch 6.x requires implementing the ChunkListener interface.

📝 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
@Slf4j
@RequiredArgsConstructor
@Component
public class ChunkListener {
@Slf4j
@RequiredArgsConstructor
@Component
public class ChunkListener implements org.springframework.batch.core.listener.ChunkListener {
🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
around lines 9 to 12, the class is annotated but does not implement the Spring
Batch 6.x ChunkListener interface; update the class declaration to implement
org.springframework.batch.core.listener.ChunkListener and add the required
@Override methods (beforeChunk, afterChunk, and afterChunkError) with
appropriate signatures and simple no-op or logging bodies so the class satisfies
the interface; ensure proper imports and keep existing annotations (@Slf4j,
@RequiredArgsConstructor, @Component).

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. Spring Batch 6.x API 변경: ChunkContext 대신 Chunk<?> 파라미터를 사용해야 합니다.
  2. Java 문자열 보간 구문 오류: ${...} 구문은 Kotlin 문법이며 Java에서는 사용할 수 없습니다. Java에서는 문자열 연결 또는 String.format을 사용해야 합니다.
🔎 제안된 수정
+import org.springframework.batch.core.scope.context.ChunkContext;
+import org.springframework.batch.item.Chunk;
+
 @Slf4j
 @RequiredArgsConstructor
 @Component
-public class ChunkListener {
+public class ChunkListener implements org.springframework.batch.core.listener.ChunkListener {

     @AfterChunk
-    void afterChunk(ChunkContext chunkContext) {
+    public void afterChunk(ChunkContext context, Chunk<?> chunk) {
         log.info(
-            "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " +
-                    "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}"
+            "청크 종료: readCount: {}, writeCount: {}",
+            context.getStepContext().getStepExecution().getReadCount(),
+            context.getStepContext().getStepExecution().getWriteCount()
         );
     }
 }

Based on learnings, Spring Batch 6.x changed ChunkListener API to use Chunk parameters with signature: afterChunk(ChunkContext context, Chunk<T> chunk).

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java
around lines 14-20, the method uses the old Spring Batch signature and Kotlin
string interpolation; change the method signature to accept the current
parameters (e.g. afterChunk(ChunkContext chunkContext, Chunk<?> chunk) or the
exact Spring Batch 6.x signature used in the project) and replace the
Kotlin-style "${...}" with Java getters and string construction — use
chunkContext.getStepContext().getStepExecution().getReadCount() and
getWriteCount() and build the log message with String.format(...) or string
concatenation so the log prints the readCount and writeCount 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

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

Line 21에서 "${jobExecution.jobInstance.jobName}"는 Java에서 유효하지 않은 문자열 보간 구문입니다. 이 코드는 실제 Job 이름 대신 리터럴 문자열을 로그에 출력합니다.

🔎 수정 제안
     @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 call uses a Java-invalid string interpolation
("${jobExecution.jobInstance.jobName}") which prints the literal rather than the
job name; replace it with a proper Java expression—either use the SLF4J
parameterized logging placeholder and pass
jobExecution.getJobInstance().getJobName() as an argument, or build the string
with + (or String.format) using jobExecution.getJobInstance().getJobName();
ensure you call the correct getter methods to obtain the job name.

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