Skip to content

[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현#412

Open
leeedohyun wants to merge 6 commits intoLoopers-dev-lab:leeedohyunfrom
leeedohyun:leeedohyun
Open

[volume-10] Spring Batch 기반 주간·월간 랭킹 시스템 구현#412
leeedohyun wants to merge 6 commits intoLoopers-dev-lab:leeedohyunfrom
leeedohyun:leeedohyun

Conversation

@leeedohyun
Copy link
Copy Markdown

@leeedohyun leeedohyun commented Apr 17, 2026

📌 Summary

  • 배경: volume-9에서 Redis Sorted Set 기반의 실시간(일간·시간 단위) 랭킹 시스템을 구축했지만, 주간·월간처럼 긴 시간 범위의 랭킹은 Redis에 적재하기에 비효율적이었다. Redis ZSET은 실시간 이벤트 스트림에 적합하지만, 7일·30일 단위 집계는 배치로 사전 계산하여 DB에 적재하는 것이 합리적이다.
  • 목표: Spring Batch를 활용하여 product_metrics 테이블의 일별 집계 데이터를 주간(7일)·월간(30일) 단위로 롤업하고, Materialized View 테이블에 적재하는 배치 Job을 구현한다. 이를 기반으로 주간·월간 랭킹 조회 API를 제공한다.
  • 결과: weeklyRankingJobmonthlyRankingJob 두 개의 Spring Batch Job을 구현했으며, 각각 GET /api/v1/rankings/weekly, GET /api/v1/rankings/monthly API로 조회할 수 있다. 기존 일간·시간 단위 랭킹(Redis)과 주간·월간 랭킹(DB)이 데이터 특성에 맞는 저장소를 사용하는 이원화 구조가 완성되었다.

🧭 Context & Decision

1. 왜 Spring Batch인가? — @scheduled로 충분하지 않은가

가장 먼저 고민한 것은 "굳이 Spring Batch를 써야 하는가"였다. 단순히 @Scheduled 메서드에서 쿼리를 실행하고 저장하면 되지 않는가?

기준 @scheduled Spring Batch
소규모 데이터 충분 과잉
대규모 데이터 (10만 상품 × 168시간) OOM 위험 / 수동 복구 Chunk 기반 메모리 안전
실패 복구 전체 재실행 체크포인트에서 재개
중복 실행 방지 수동 관리 JobInstance 자동 관리
실행 이력 수동 로깅 메타데이터 테이블 자동 기록

현재 데이터 규모에서는 @scheduled로도 동작하지만, 상품 수가 늘어나면 Chunk 기반 처리와 체크포인트 복구가 필요해진다. 또한 배치의 4가지 핵심 문제(Chunking, Checkpointing, Idempotency, Monitoring)를 프레임워크가 해결해주므로 Spring Batch를 선택했다.

2. Chunk vs Tasklet — 현재 규모에 맞는 선택

Spring Batch의 Step 패턴은 Chunk(Reader → Processor → Writer)와 Tasklet 두 가지가 있다.

패턴 적합한 상황 현재 케이스
Chunk 대량 데이터를 N건씩 반복 처리 Reader 단계에서 SQL GROUP BY로 이미 집계된 결과(최대 100건)를 반환
Tasklet 단일 작업 단위로 완결 집계 쿼리 1회 → 결과 저장 1회로 충분

핵심 판단 근거: 집계 쿼리가 GROUP BY product_id로 상위 100개만 반환하므로, Reader가 반복적으로 데이터를 가져올 필요가 없다. 한 번의 쿼리로 완결되는 작업에 Chunk 패턴을 적용하면 불필요한 구조 복잡도만 늘어난다. 향후 데이터 규모가 커져서 페이징이 필요해지면 Chunk 패턴으로 전환할 수 있다.

3. Materialized View 설계 — MySQL의 한계와 대안

PostgreSQL/Oracle은 CREATE MATERIALIZED VIEW를 지원하지만 MySQL은 지원하지 않는다. 따라서 별도 테이블(mv_product_rank_weekly, mv_product_rank_monthly)을 생성하고 배치가 주기적으로 갱신하는 방식으로 구현했다.

Refresh 전략: Upsert (INSERT ... ON DUPLICATE KEY UPDATE)

전략 Full Refresh (DROP + 재생성) Incremental (Upsert)
구현 복잡도 단순 약간 복잡
멱등성 보장 (product_id, score_date) 유니크 제약으로 보장
동시 접근 테이블 잠금 필요 행 단위 갱신으로 안전

Upsert를 선택한 이유: 같은 date 파라미터로 재실행해도 동일한 결과가 보장되어 멱등성을 자연스럽게 달성한다. Full Refresh는 테이블을 비우는 순간 API가 빈 결과를 반환하는 윈도우가 생기지만, Upsert는 기존 데이터를 유지하면서 갱신한다.

4. product_metrics 시간 단위 변경 — 시간 단위 → 일 단위

기존 product_metrics는 시간(hour) 단위로 적재되어 있었다. 주간 집계 시 한 상품당 최대 168행(7일 × 24시간)을, 월간 집계 시 최대 720행(30일 × 24시간)을 읽어야 했다.

일(day) 단위로 변경하면 주간 7행, 월간 30행으로 줄어든다. 현재 시스템에서 시간 단위 세분화가 필요한 요구사항이 없었으므로, 일 단위로 리팩터링하고 metric_date 컬럼에 인덱스를 추가했다.

5. 외부 트리거 vs @scheduled — 배치 실행 방식

방식 외부 트리거 (CLI 파라미터) @scheduled
리소스 효율 실행 시만 자원 사용 24/7 상주
파라미터 유연성 커맨드라인으로 날짜 지정 가능 코드에 고정
과거 데이터 백필 date=20260401로 즉시 가능 불가
멀티 인스턴스 스케줄러가 단일 호출 보장 분산 락 필요

외부 트리거를 선택했다. 주간 배치는 일주일에 한 번, 약 수 분 실행된 후 나머지 시간은 유휴 상태다. 또한 date 파라미터를 통해 과거 데이터 백필이 자유로워 운영 유연성이 높다.

🏗️ Design Overview

변경 범위

  • commerce-batch: 주간·월간 랭킹 집계 배치 Job (Tasklet 기반)
  • commerce-api: 주간·월간 랭킹 조회 API, 도메인 서비스 확장
  • commerce-streamer: product_metrics 시간 단위를 일 단위로 변경, 인덱스 추가

신규 추가

commerce-batch — Domain Layer

  • ProductMetrics: 일별 상품 메트릭 엔티티 (viewCount, likeCount, orderCount)
  • ProductMetricsRepository: 가중 스코어 상위 N개 조회 포트
  • ProductScoreProjection: 집계 결과 프로젝션 (productId, score)
  • ProductRankingWeekly / ProductRankingMonthly: 랭킹 엔티티
  • ProductRankingWeeklyRepository / ProductRankingMonthlyRepository: upsert 저장 포트

commerce-batch — Infrastructure Layer

  • ProductMetricsJpaRepository: JPQL 기반 가중 스코어 집계 쿼리
  • ProductRankingWeeklyJpaRepository / ProductRankingMonthlyJpaRepository: Native Query upsert (ON DUPLICATE KEY UPDATE)

commerce-batch — Batch Layer

  • WeeklyRankingJobConfig / MonthlyRankingJobConfig: Job + Step 구성, @ConditionalOnProperty로 Job 선택 실행
  • WeeklyRankingTasklet / MonthlyRankingTasklet: JobParameter date를 기준으로 직전 7/30일 집계 후 upsert

commerce-api — Domain Layer

  • WeeklyRankingRepository / MonthlyRankingRepository: 조회 포트
  • ProductRankingWeekly / ProductRankingMonthly: 읽기 전용 엔티티
  • RankingService: 주간·월간 조회 메서드 추가

commerce-api — Application Layer

  • ReadWeeklyRankingsUseCase / ReadMonthlyRankingsUseCase: 랭킹 조회 UseCase
  • RankingItem.toRankingItems(): 제네릭 변환 유틸리티 (DB 엔티티 → 랭킹 항목)

commerce-api — Interfaces Layer

  • RankingV1Api: GET /api/v1/rankings/weekly, GET /api/v1/rankings/monthly 엔드포인트 추가

주요 컴포넌트 책임

컴포넌트 모듈 책임
WeeklyRankingTasklet batch date 기준 직전 7일 product_metrics 집계 → mv_product_rank_weekly upsert
MonthlyRankingTasklet batch date 기준 직전 30일 product_metrics 집계 → mv_product_rank_monthly upsert
ProductMetricsJpaRepository batch 가중 스코어 SQL 집계 (view×0.1 + like×0.2 + order×0.7)
RankingService api 저장소별 랭킹 조회 라우팅 (Redis: 일간/시간, DB: 주간/월간)
RankingItem.toRankingItems() api DB 엔티티 → 순위 포함 RankingItem 변환 (기존 Redis 기반 변환과 통일)
RankingResultAssembler api 랭킹 + 상품 캐시 + 좋아요 상태 조합 (기존 컴포넌트 재사용)

🔁 Flow Diagram

배치 실행 흐름: product_metrics → Materialized View

sequenceDiagram
    autonumber
    participant CLI as External Trigger
    participant Job as WeeklyRankingJob
    participant Tasklet as WeeklyRankingTasklet
    participant MetricsRepo as ProductMetricsRepository
    participant RankingRepo as ProductRankingWeeklyRepository
    participant DB as MySQL

    CLI->>Job: bootRun --args="--spring.batch.job.name=weeklyRankingJob date=20260413"
    Job->>Tasklet: execute(contribution, chunkContext)
    Tasklet->>Tasklet: date 파라미터 파싱 → start=04/06, end=04/12
    Tasklet->>MetricsRepo: findTopScores(start, end, 100)
    MetricsRepo->>DB: SELECT product_id, SUM(view×0.1 + like×0.2 + order×0.7)<br/>FROM product_metrics WHERE metric_date BETWEEN ... GROUP BY product_id LIMIT 100
    DB-->>MetricsRepo: List<ProductScoreProjection>
    MetricsRepo-->>Tasklet: Top 100 스코어
    Tasklet->>RankingRepo: saveAll(rankings)
    RankingRepo->>DB: INSERT INTO mv_product_rank_weekly ... ON DUPLICATE KEY UPDATE score = :score
    DB-->>RankingRepo: upsert 완료
    Tasklet-->>Job: RepeatStatus.FINISHED
Loading

API 조회 흐름: 주간/월간 랭킹

sequenceDiagram
    autonumber
    participant Client
    participant API as RankingV1Api
    participant UseCase as ReadWeeklyRankingsUseCase
    participant DomainSvc as RankingService
    participant Repo as WeeklyRankingRepository
    participant DB as MySQL
    participant Assembler as RankingResultAssembler

    Client->>API: GET /api/v1/rankings/weekly?date=20260413&page=0&size=20
    API->>UseCase: execute(userId, date, pageSize)
    UseCase->>DomainSvc: readWeeklyTopRanked(scoreDate, page, size)
    DomainSvc->>Repo: readTopRanked(scoreDate, page, size)
    Repo->>DB: SELECT * FROM mv_product_rank_weekly WHERE score_date = ? ORDER BY score DESC
    DB-->>UseCase: List<ProductRankingWeekly>
    UseCase->>UseCase: RankingItem.toRankingItems(rankings, offset)
    UseCase->>Assembler: assemble(userId, rankingItems, pageSize)
    Assembler-->>API: RankingPageResult (상품 정보 + brandId + 좋아요 여부)
    API-->>Client: RankingResponse
Loading

✅ Checklist

🧱 Spring Batch

  • Spring Batch Job을 작성하고, 파라미터 기반으로 동작시킬 수 있다 — WeeklyRankingJobConfig, MonthlyRankingJobConfig에서 @ConditionalOnProperty로 Job 선택, date JobParameter로 집계 기준일 전달
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) 기반의 배치 처리를 구현했다 — WeeklyRankingTasklet, MonthlyRankingTasklet에서 Tasklet 패턴으로 구현 (집계 SQL 1회 → 결과 저장 1회로 완결되는 작업이므로 Tasklet 선택)
  • 집계 결과를 저장할 Materialized View의 구조를 설계하고 올바르게 적재했다 — mv_product_rank_weekly, mv_product_rank_monthly 테이블에 (product_id, score_date) 유니크 제약 + ON DUPLICATE KEY UPDATE로 멱등성 보장

🧩 Ranking API

  • API가 일간, 주간, 월간 랭킹을 제공하며 조회해야 하는 형태에 따라 적절한 데이터를 기반으로 랭킹을 제공한다 — 일간/시간 단위는 Redis Sorted Set, 주간/월간은 배치 집계 DB 기반으로 이원화하여 제공

🧪 검증

  • Tasklet 단위 테스트 — WeeklyRankingTaskletTest, MonthlyRankingTaskletTest (Mockito 기반, 파라미터 검증·집계 결과 저장 검증)
  • Job E2E 테스트 — WeeklyRankingJobE2ETest, MonthlyRankingJobE2ETest (Testcontainers + @SpringBatchTest, 실제 DB 집계·멱등성 검증)
  • Repository 통합 테스트 — WeeklyRankingRepositoryImplIntegrationTest, MonthlyRankingRepositoryImplIntegrationTest (scoreDate 필터링, 페이지네이션, 정렬 검증)
  • API E2E 테스트 — RankingV1ApiE2ETest에 주간·월간 랭킹 테스트 추가 (순위 정렬, 상품 정보 Aggregation, 페이지네이션 검증)

🤖 Generated with Claude Code

주간·월간 랭킹 시스템 DB 기반 구현

변경 목적

Redis 기반의 실시간 랭킹에 보완하여 일일 집계 메트릭으로부터 계산된 주간(7일)·월간(30일) 랭킹을 Materialized View 테이블(mv_product_rank_weekly/monthly)에 사전 계산하고 API로 제공. Spring Batch 기반 배치 작업으로 매일 실행 가능한 체계 구축.

핵심 변경점

  1. Spring Batch 작업: WeeklyRankingJobConfig/MonthlyRankingJobConfig에서 Tasklet 기반 작업 정의. ProductMetricsRepository.findTopScores(start, end, 100)로 상위 100개 상품 조회 후 스코어 공식(view×0.1 + like×0.2 + order×0.7)으로 계산된 스코어를 DB에 Upsert(ON DUPLICATE KEY UPDATE)로 저장하여 멱등성 보장.

  2. 신규 API 엔드포인트: /api/v1/rankings/weekly·/monthly 추가로 기존 daily/hourly(Redis 기반)와 함께 제공. 동일한 RankingPageResult 응답 형식 사용으로 클라이언트 호환성 유지.

  3. ProductMetrics 단위 변경: 시간(metricHour) → 일(metricDate)로 데이터 단위 전환. Streamer와 Batch가 각각 별도의 ProductMetrics 클래스를 정의하고 있으며, 둘 다 이미 LocalDate metricDatemetric_date 테이블 구조로 변경됨.

리스크 및 주의사항

  • ProductMetrics 이중 정의: apps/commerce-batchapps/commerce-streamer에 동일명의 클래스가 분리되어 존재. 향후 스키마 변경 시 두 위치를 동시에 반영해야 함. 스키마 동기화 자동화 메커니즘 필요 여부 검토 권장.
  • 데이터베이스 마이그레이션 파일 부재: 테이블 구조 변경(metricHour → metricDate)에 대한 마이그레이션 스크립트나 Flyway 파일이 확인되지 않음. 기존 시스템과의 호환성 및 롤백 전략 확인 필요.
  • 배치 실행 방식: 외부 CLI 파라미터(date)로만 트리거되며, 스케줄 설정이 포함되지 않음. 정기적 실행을 위한 별도의 스케줄러 또는 워크플로우 구성 필요 여부 확인.

테스트 커버리장

Tasklet 단위 테스트(Mockito), Spring Batch E2E 테스트(@SpringBatchTest), 멱등성 검증(동일 date로 run.id만 변경하여 재실행), Repository 통합 테스트 포함. 약 900+ 줄의 테스트 코드로 주간/월간 집계 로직과 Upsert 동작 검증.

leeedohyun and others added 5 commits April 14, 2026 10:45
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 17, 2026 01:17
@leeedohyun
Copy link
Copy Markdown
Author

@coderabbitai ignore

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

주간 및 월간 제품 랭킹 기능을 추가하는 변경이다. 배치 작업을 통해 메트릭 데이터를 집계하여 읽기 전용 뷰 테이블에 저장하고, 이를 API 엔드포인트로 노출한다. 메트릭 그래뉼래리티도 시간 단위에서 일 단위로 변경한다.

Changes

Cohort / File(s) Summary
Domain Layer - Weekly/Monthly Ranking Entities
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java
읽기 전용 뷰 테이블(mv_product_rank_weekly, mv_product_rank_monthly)에 매핑되는 JPA 엔티티 추가. 각각 productId, scoreDate, score 필드 보유 및 (score_date, score) 인덱스 정의한다.
Domain Layer - Repository Interfaces
apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java
페이징을 지원하는 readTopRanked(LocalDate scoreDate, int page, int size) 메서드를 정의하는 도메인 리포지토리 인터페이스 추가한다.
Domain Layer - RankingItem & RankingService
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java, apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
RankingItem에 제네릭 변환 메서드 toRankingItems(...) 추가 및 RankingServicereadWeeklyTopRanked(), readMonthlyTopRanked() 메서드 추가한다.
Infrastructure Layer - JPA & Repository Implementation
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java
Spring Data JPA 쿼리 메서드 및 읽기 전용 트랜잭션으로 래핑된 구현체를 추가한다.
Application Layer - Use Cases
apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java, apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java
날짜 파싱, 랭킹 조회, 상품 정보 및 좋아요 상태 조립을 수행하는 사용 사례 클래스 추가한다.
API Layer
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java
GET /api/v1/rankings/weekly, GET /api/v1/rankings/monthly 엔드포인트 및 OpenAPI 스펙 추가한다.
Batch Layer - Job Configuration
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java
Spring Batch 작업 및 스텝 빈을 조건부로 등록하는 배치 구성 클래스 추가한다.
Batch Layer - Tasklets
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java
메트릭을 집계하여 주간(7일) 및 월간(30일) 랭킹을 계산하고 저장하는 배치 태스클릿 추가한다.
Batch Domain - Metrics & Ranking Entities
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java, apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java, apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java, apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java, apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java, apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java
메트릭 엔티티, 스코어 프로젝션 및 배치 작업용 랭킹 리포지토리(upsert 지원) 추가한다.
Batch Infrastructure - JPA & Repository Implementations
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/..., apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/...
가중 합계를 이용한 점수 집계 쿼리 및 ON DUPLICATE KEY UPDATE 기반 upsert 구현을 추가한다.
Streamer Layer - Metric Granularity Change
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java, apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java, apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java
메트릭 시간 단위를 LocalDateTime(metric_hour)에서 LocalDate(metric_date)로 변경하고 관련 쿼리 및 테스트 갱신한다.
API Integration Tests
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java, apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java
리포지토리 및 API 엔드포인트의 페이징, 정렬, 빈 결과 처리를 검증하는 통합 및 E2E 테스트 추가한다.
Batch Tests
apps/commerce-batch/src/test/java/com/loopers/job/ranking/..., apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
배치 작업 실패 시나리오(누락된 매개변수), 집계 로직, 멱등성, 날짜 범위 계산, upsert 동작을 검증하는 단위 및 통합 테스트 추가한다.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as API Controller
    participant UseCase as ReadWeekly/MonthlyRankingsUseCase
    participant Service as RankingService
    participant Repo as Weekly/MonthlyRankingRepository
    participant Assembler as RankingResultAssembler
    participant DB as Database

    User->>API: GET /api/v1/rankings/weekly<br/>(userId, date, page, size)
    API->>UseCase: execute(userId, date, pageSize)
    UseCase->>UseCase: Parse date (yyyyMMdd → LocalDate)
    UseCase->>Service: readWeeklyTopRanked(scoreDate, page, size)
    Service->>Repo: readTopRanked(scoreDate, page, size)
    Repo->>DB: findByScoreDateOrderByScoreDesc<br/>(scoreDate, pageable)
    DB-->>Repo: List<ProductRankingWeekly>
    Repo-->>Service: List<ProductRankingWeekly>
    Service-->>UseCase: List<ProductRankingWeekly>
    UseCase->>UseCase: toRankingItems(products, offset,<br/>mapper functions)
    UseCase->>Assembler: assemble(userId, rankingItems, pageSize)
    Assembler->>Assembler: Fetch product details & brand info<br/>Fetch user likes
    Assembler-->>UseCase: RankingPageResult
    UseCase-->>API: RankingPageResult
    API-->>User: ApiResponse<RankingResponse>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

검토 시 주의 사항:

  1. 배치 작업의 날짜 범위 계산 검증 필수: WeeklyRankingTaskletMonthlyRankingTasklet에서 각각 이전 7일/30일을 정확히 계산하는지 확인이 필요하다. 특히 start = baseDate - N days, end = baseDate - 1 day의 로직이 국가별 타임존 변경이나 윤년 처리 시 정확한지 운영 환경에서 테스트해야 한다.

  2. Upsert 쿼리의 동시성 문제: ProductRankingMonthlyJpaRepositoryProductRankingWeeklyJpaRepositoryON DUPLICATE KEY UPDATE 쿼리가 MySQL에서만 작동한다. 데이터베이스 변경 시 INSERT ... ON CONFLICT ... DO UPDATE (PostgreSQL) 등으로 수정이 필요하며, 이를 미리 문서화해야 한다.

  3. 메트릭 그래뉼래리티 변경의 영향도 검토: product_metrics.metric_hourproduct_metrics.metric_date 변경으로 인해 기존 시간 단위 데이터는 손실된다. 데이터 마이그레이션 스크립트 또는 롤백 계획이 필요하며, 이 변경 이전 배포 시점에서 충분한 공지가 필요하다.

  4. 성능 최적화: findByScoreDateOrderByScoreDescscore_date에 인덱스가 있지만, 정렬 시에도 인덱스를 활용하려면 (score_date, score DESC) 복합 인덱스가 권장된다. 현재 정의된 인덱스(idx_score_date_score)의 열 순서가 쿼리 패턴과 일치하는지 확인이 필요하다.

  5. 테스트 커버리지: 배치 작업의 실패 시나리오(date 파라미터 누락)는 테스트되지만, 다음 케이스도 추가 검토를 권장한다:

    • 범위 내 메트릭이 없는 경우의 집계 동작
    • 점수 값이 NULL이거나 음수인 경우의 처리
    • 대량 데이터(100+ 제품) 처리 시의 성능
  6. 매개변수 검증: PageSize.withMaxSize(page, size)의 최대값 제한이 일관되는지 확인이 필요하다. 특히 매우 큰 page 값 입력 시 메모리 폭발을 방지하도록 상한선이 설정되어 있는지 검토해야 한다.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주간·월간 랭킹 시스템의 Spring Batch 기반 구현이라는 핵심 변경사항을 명확히 반영하고 있다.
Description check ✅ Passed PR 설명이 template의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 포함하고 있으며, 설계 결정 근거를 상세하게 기록하고 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@leeedohyun leeedohyun self-assigned this Apr 17, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

주간·월간 랭킹을 Spring Batch로 집계해 DB(Materialized View 테이블)에 적재하고, API에서 해당 랭킹을 조회할 수 있도록 확장합니다. 또한 product_metrics 집계 단위를 시간→일로 변경해 장기 집계 비용을 줄입니다.

Changes:

  • Spring Batch(Tasklet) 기반 weeklyRankingJob / monthlyRankingJob 추가 및 upsert 적재 구현
  • 주간/월간 랭킹 조회 API 및 DB 조회 리포지토리/유스케이스/도메인 확장
  • product_metrics를 일 단위(metric_date)로 전환 및 관련 테스트 보강

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java metrics 집계 단위 변경(hour→date)에 맞춰 테스트 수정
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java product_metrics upsert 컬럼을 metric_date 기반으로 변경
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java metric_date 전환 및 인덱스/유니크 제약 갱신
apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java 주간 Tasklet 단위 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java 주간 Job E2E 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java 주간 랭킹 upsert 저장 통합 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java 월간 랭킹 upsert 저장 통합 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java 월간 Tasklet 단위 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java 월간 Job E2E 테스트 추가
apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java 배치 자동 실행 비활성화로 컨텍스트 로드 안정화
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java 주간 랭킹 upsert 저장 구현체 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java 주간 랭킹 upsert 네이티브 쿼리 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java 월간 랭킹 upsert 저장 구현체 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyJpaRepository.java 월간 랭킹 upsert 네이티브 쿼리 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImpl.java 집계 쿼리 호출 어댑터 추가
apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java 주간/월간 스코어 집계 JPQL 추가
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java 주간 랭킹 저장 포트 추가
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java 주간 MV 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthlyRepository.java 월간 랭킹 저장 포트 추가
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java 월간 MV 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java 스코어 집계 결과 프로젝션 추가
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java 스코어 조회 포트 추가
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java 배치 모듈용 product_metrics 엔티티 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/WeeklyRankingTasklet.java 주간 집계 Tasklet 구현
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java 월간 집계 Tasklet 구현
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java 주간 Job/Step 구성 추가
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java 월간 Job/Step 구성 추가
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java 주간/월간 랭킹 API E2E 테스트 추가
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingSteps.java 주간/월간 랭킹 호출 스텝 추가
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java 주간 랭킹 조회 리포지토리 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImplIntegrationTest.java 월간 랭킹 조회 리포지토리 통합 테스트 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java 주간/월간 랭킹 API 스펙 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java 주간/월간 랭킹 엔드포인트 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java 주간 랭킹 DB 조회 구현체 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingJpaRepository.java 주간 랭킹 JPA 조회 리포지토리 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java 월간 랭킹 DB 조회 구현체 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java 월간 랭킹 JPA 조회 리포지토리 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/WeeklyRankingRepository.java 주간 랭킹 조회 포트 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java Redis(일/시간) + DB(주/월) 라우팅 확장
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingItem.java DB 엔티티→RankingItem 변환 유틸 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java 주간 MV 읽기 엔티티 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java 월간 MV 읽기 엔티티 추가
apps/commerce-api/src/main/java/com/loopers/domain/ranking/MonthlyRankingRepository.java 월간 랭킹 조회 포트 추가
apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java 주간 랭킹 조회 유스케이스 추가
apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java 월간 랭킹 조회 유스케이스 추가
Comments suppressed due to low confidence (3)

apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java:1

  • LocalDate.now()는 테스트 실행 시각/타임존(특히 DB의 CURDATE() 기준 타임존) 및 자정 경계에 따라 간헐적으로 실패할 수 있습니다. 테스트 안정성을 위해 고정 날짜(예: LocalDate.of(...))를 사용하거나, 애플리케이션/DB가 공유하는 Clock 또는 테스트용 기준일을 주입해 검증 기준을 고정하는 방식으로 바꾸는 것을 권장합니다.
    apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java:1
  • 현재 saveAll이 랭킹 1건당 upsert 1회로 수행되어(최대 100건이더라도) DB round-trip이 누적됩니다. INSERT ... VALUES (...), (...), ... ON DUPLICATE KEY UPDATE ... 형태의 멀티-row upsert(또는 JdbcTemplate#batchUpdate)로 합치면 네트워크/DB 부하를 줄이고 배치 실행 시간을 안정화할 수 있습니다. (권장 수정)
    apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java:1
  • 월간도 주간과 동일하게 1건당 upsert 1회로 수행되어 DB 호출이 N번 발생합니다. 멀티-row upsert(또는 JDBC 배치)로 변경해 round-trip을 줄이는 편이 운영/성능 측면에서 유리합니다. (권장 수정)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +20

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "mv_product_rank_weekly", indexes = {
@Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
})
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

@Index(columnList = \"score_date, score DESC\")에서 DESC 지정은 JPA 표준이 아니며, JPA 구현체/DDL 생성 설정에 따라 스키마 생성 시 오류를 유발할 수 있습니다. 또한 배치 모듈은 (product_id, score_date) 유니크 제약에 의존해 upsert 멱등성을 보장하는데, API 모듈 엔티티에는 동일한 유니크 제약이 선언되어 있지 않아(스키마 자동 생성/검증을 켠 환경에서) 모듈 간 스키마 정의가 불일치할 수 있습니다. DESC는 제거하고(정렬 최적화는 실제 DB 마이그레이션으로 관리), 유니크 제약은 배치/조회 양쪽 엔티티에서 동일하게 유지하는 것을 권장합니다.

Suggested change
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "mv_product_rank_weekly", indexes = {
@Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
})
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(
name = "mv_product_rank_weekly",
uniqueConstraints = {
@UniqueConstraint(name = "uk_product_rank_weekly_product_date", columnNames = {"product_id", "score_date"})
},
indexes = {
@Index(name = "idx_score_date_score", columnList = "score_date, score")
}
)

Copilot uses AI. Check for mistakes.
*/
@Entity
@Table(name = "mv_product_rank_monthly", indexes = {
@Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

주간 엔티티와 동일하게 @IndexcolumnListDESC를 포함하는 것은 표준이 아니어서 DDL 생성/검증 환경에서 실패할 수 있습니다. 정렬 인덱스는 마이그레이션에서 명시적으로 관리하고, 엔티티는 컬럼명만 지정하는 형태로 맞추는 것을 권장합니다.

Suggested change
@Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
@Index(name = "idx_score_date_score", columnList = "score_date, score")

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@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: 14

Caution

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

⚠️ Outside diff range comments (1)
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

22-39: ⚠️ Potential issue | 🟠 Major

스트리머의 CURDATE() 평가 타임존과 배치의 LocalDate 타임존 일치를 확인해야 한다.

현재 JDBC URL에 serverTimezone 파라미터가 없어 MySQL이 서버의 기본 타임존을 사용하게 되고, 이것이 애플리케이션의 Asia/Seoul 타임존과 일치한다는 보장이 없다. 스트리머의 CURDATE() (native SQL, DB에서 평가됨)로 기록된 metric_date와 배치가 JVM 타임존(Asia/Seoul)에서 파싱한 LocalDate로 쿼리하는 범위가 자정 경계에서 어긋날 수 있다. 예를 들어 23:59:59 KST에 들어온 이벤트가 스트리머에는 UTC 기준 전날로, 배치 쿼리에는 당일로 기록되어 당일 이벤트가 누락되거나 중복 집계될 위험이 있다. 시간 단위였던 기존 스키마보다 경계 영향 범위가 24배 증가한다.

권고사항:

  • JDBC URL에 serverTimezone=Asia/Seoul 추가 또는 serverTimezone=UTC로 명시하고 모든 애플리케이션의 타임존 설정과 단일 기준으로 정렬한다.
  • 또는 스트리머 upsert에서 CURDATE() 대신 애플리케이션에서 계산한 LocalDate 파라미터를 주입하여 DB 타임존 의존을 제거한다.

추가 테스트:

  • 자정 경계 이벤트(23:59:59 / 00:00:00)를 주입하고 스트리머와 배치가 동일한 버킷에 기록하는지 검증하는 통합 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 22 - 39, ProductMetrics' metric_date can drift between DB CURDATE()
and JVM LocalDate; fix by either specifying serverTimezone (e.g.,
serverTimezone=Asia/Seoul or =UTC) in the application's JDBC URL so DB CURDATE()
and the app JVM share the same TZ, or change the streamer upsert to stop using
CURDATE() and instead pass an application-computed LocalDate parameter into the
upsert (affecting the code that writes metric_date in the streamer/upsert SQL);
additionally add an integration test that injects events at 23:59:59 and
00:00:00 and asserts ProductMetrics entries (productId/metricDate) are recorded
into the same expected date bucket to prevent midnight-boundary mismatches.
♻️ Duplicate comments (1)
apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java (1)

77-85: ⚠️ Potential issue | 🟡 Minor

Monthly 동일 테스트와 같은 격리 이슈가 반복된다.

앞선 테스트들이 동일한 SCORE_DATEmv_product_rank_weekly에 데이터를 적재하므로, BaseIntegrationTest의 clean-up 전략이 없다면 returnsEmptyList_whenNoData가 실행 순서에 따라 실패할 수 있다. Monthly 테스트의 동일 지적(루트 원인)과 함께 한 번에 정리하면 된다. 별도 scoreDate를 사용하거나 @BeforeEach에서 삭제 처리를 추가하기를 권고한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java`
around lines 77 - 85, 테스트 격리 문제: WeeklyRankingRepositoryImplIntegrationTest의
returnsEmptyList_whenNoData가 다른 테스트가 같은 SCORE_DATE로 mv_product_rank_weekly에 남긴
데이터 때문에 순서에 따라 실패할 수 있으니, 테스트를 격리하도록 수정하세요; 해결 방법은 (1)
returnsEmptyList_whenNoData 또는 해당 클래스의 테스트에서 readTopRanked 호출에 사용하는 SCORE_DATE를
고유한 값으로 변경하거나 (2) WeeklyRankingRepositoryImplIntegrationTest에 `@BeforeEach` 메서드를
추가해 mv_product_rank_weekly에서 SCORE_DATE 관련 레코드를 삭제하도록 정리하는 것입니다 — 참고로 영향을 주는 심볼:
returnsEmptyList_whenNoData, SCORE_DATE, readTopRanked,
WeeklyRankingRepositoryImplIntegrationTest, BaseIntegrationTest,
mv_product_rank_weekly.
🧹 Nitpick comments (17)
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java (1)

35-35: 자정 경계에서 플래키 가능성이 있다.

CURRENT_METRIC_DATE는 클래스 로딩 시점의 LocalDate.now()로 고정되지만, upsertXxx 내부에서 결정되는 metricDate는 실제 upsert 실행 시각을 기준으로 한다. 테스트가 자정 직전에 로딩되어 upsert가 자정 이후 실행되면 기대값과 실제값의 LocalDate가 달라져 CI에서 간헐적으로 실패할 수 있다. 또한 타임존이 기본 시스템 타임존에 암묵적으로 의존하므로, 실행 환경(UTC 컨테이너 등)이 달라질 경우에도 결과가 달라질 여지가 있다.

운영 관점에서 배치/스트리머의 날짜 기준과 테스트의 날짜 기준을 동일 소스(시계 주입 또는 프로덕션 코드 내부 LocalDate)로 맞추는 것이 안전하다.

♻️ 수정안 예시
-    private static final LocalDate CURRENT_METRIC_DATE = LocalDate.now();
+    // 생산 코드와 동일한 타임존/시계 소스를 사용하거나, 실행 시점에 조회해 자정 경계 이슈를 줄인다.
+    private LocalDate currentMetricDate() {
+        return LocalDate.now();
+    }

그리고 단언을 currentMetricDate()로 교체하고, 장기적으로는 생산 코드에 Clock을 주입해 고정된 시점으로 테스트하도록 리팩터링을 권장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java`
at line 35, CURRENT_METRIC_DATE is set at class load via LocalDate.now() which
can drift across midnight and different timezones causing flaky tests; replace
uses of the static CURRENT_METRIC_DATE with a call to a helper method
currentMetricDate() so the test computes the expected metric date at assertion
time (matching the upsertXxx decision), and update tests to use a fixed Clock or
inject the same Clock used by the production upsert logic (prefer refactoring
production code to accept a java.time.Clock) so both test and upsertXxx compute
metricDate from the same time source.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java (1)

19-25: 단건 upsert 반복으로 인한 라운드트립 비용이 문제다.

rankings.forEach(...)로 원소마다 개별 네이티브 쿼리를 실행한다. 상위 100건 기준이라 지금은 큰 이슈가 아니지만, 운영 관점에서 다음 리스크가 있다:

  • 배치 Job이 커질 경우(예: 백필로 다중 날짜를 한 번에 upsert하거나 상한이 늘어날 경우) N회 네트워크 라운드트립 및 트랜잭션 로그 비용이 선형 증가한다.
  • @Modifying + 개별 쿼리는 JDBC batch가 자동 적용되지 않으므로, hibernate.jdbc.batch_size 설정만으로는 묶여 나가지 않는다.

운영/성능 관점에서 단일 멀티-로우 INSERT ... VALUES (...), (...), ... ON DUPLICATE KEY UPDATE score = VALUES(score) 쿼리로 변경하거나, JdbcTemplate.batchUpdate로 명시적 배치 처리를 권장한다. 또한 통합 테스트에 List<ProductRankingMonthly> 크기 N(예: 100)을 한 번에 저장했을 때 실제로 1~2개의 쿼리로 나가는지 검증하는 테스트(예: @DataJpaTest + Hibernate Statistics, 또는 P6Spy/datasource-proxy 기반 카운트) 추가를 제안한다.

♻️ 수정안 예시 (단일 쿼리 멀티-로우 upsert)
-    `@Override`
-    `@Transactional`
-    public void saveAll(List<ProductRankingMonthly> rankings) {
-        rankings.forEach(r ->
-                productRankingMonthlyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore())
-        );
-    }
+    `@Override`
+    `@Transactional`
+    public void saveAll(List<ProductRankingMonthly> rankings) {
+        if (rankings.isEmpty()) {
+            return;
+        }
+        productRankingMonthlyJpaRepository.upsertAll(rankings);
+    }

upsertAllJdbcTemplate 기반 별도 컴포넌트에서 INSERT ... VALUES (?,?,?), ... ON DUPLICATE KEY UPDATE score = VALUES(score) 한 번으로 구현한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java`
around lines 19 - 25, saveAll currently iterates and calls
productRankingMonthlyJpaRepository.upsert per item causing N round-trips;
replace this with a single batch upsert: add a new method (e.g., upsertAll) in
ProductRankingMonthlyRepositoryImpl that uses JdbcTemplate (or
JdbcTemplate.batchUpdate) to execute one multi-row "INSERT ... VALUES
(...),(...)" with "ON DUPLICATE KEY UPDATE score=VALUES(score)" for all
List<ProductRankingMonthly> entries (or build and execute a single SQL string
with bound params), stop using per-item
productRankingMonthlyJpaRepository.upsert in saveAll and delegate to upsertAll,
and add an integration test (e.g., `@DataJpaTest` using Hibernate Statistics or
P6Spy/datasource-proxy) that persists ~100 ProductRankingMonthly and asserts the
number of executed SQL statements is 1–2.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java (1)

11-13: 파생 쿼리의 인덱스 활용과 대용량 조회 성능 검증이 필요하다.

파생 쿼리 findByScoreDateOrderByScoreDescProductRankingMonthlyidx_score_date_score(score_date, score DESC) 인덱스를 정확히 활용할 수 있는 형태다. 다만 운영 관점에서 다음을 확인한다:

  • 총 개수(count)가 필요 없는 조회이므로 Page<T> 대신 List<T> + Pageable을 사용한 선택은 적절하다(불필요한 count 쿼리 회피).
  • 페이지 크기 상한은 PageSize.withMaxSize()로 100으로 제한되어 있으므로 과도한 페이지 크기 요청은 방지된다.
  • 다만 대용량 시나리오에서 실제로 idx_score_date_score가 사용되고 filesort가 발생하지 않는지 EXPLAIN 기반 통합 테스트 또는 리뷰 체크리스트 항목으로 검증할 것을 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java`
around lines 11 - 13, Add an integration test that verifies the derived query in
MonthlyRankingJpaRepository (findByScoreDateOrderByScoreDesc) actually uses the
idx_score_date_score(index on score_date, score DESC) and does not trigger a
filesort for large datasets: execute an EXPLAIN for the SQL generated by
findByScoreDateOrderByScoreDesc against a populated test table
(ProductRankingMonthly) with realistic volume and assert the EXPLAIN output
shows the index is used and no "filesort" appears; include test setup using
Pageable with PageSize.withMaxSize() to ensure page size limits and run this as
part of CI so index usage is validated automatically.
apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java (1)

11-23: 도메인 인터페이스에 MySQL 특화 구문(ON DUPLICATE KEY UPDATE)이 언급되는 점을 재고할 것을 권장한다.

도메인 계층은 인프라 종속성을 숨기는 것이 원칙이다. 상세 구현(MySQL 문법)을 Javadoc에 명시하면 추후 DB 교체/테스트 DB 변경 시 계약과 구현이 어긋날 수 있다. "멱등성 보장 upsert"라는 의도만 도메인 문서에 남기고, 구현 수단은 ProductRankingWeeklyJpaRepository/Impl 쪽 주석에 한정한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java`
around lines 11 - 23, The Javadoc on ProductRankingWeeklyRepository currently
mentions MySQL-specific syntax; change the doc on the interface
(ProductRankingWeeklyRepository and its saveAll(List<ProductRankingWeekly>
rankings) method) to describe only the intent: that saveAll performs an
idempotent upsert (updates score when a (productId, scoreDate) exists, otherwise
creates), and remove any reference to MySQL or ON DUPLICATE KEY UPDATE; move any
implementation-specific notes about using MySQL upsert syntax into the
infrastructure layer (e.g., ProductRankingWeeklyJpaRepository or its Impl)
Javadoc or implementation comments.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java (1)

26-36: date 파라미터 포맷/필수 여부가 스펙 문서화에 드러나지 않는다.

운영 관점에서 구현체(RankingV1Api)는 @RequestParam String date로 필수값이나, OpenAPI 문서에는 포맷(yyyyMMdd)·필수 여부가 드러나지 않아 클라이언트 연동 시 오해를 유발한다. @Parameter(description = "yyyyMMdd 형식", required = true, example = "20260414") 등을 추가해 일/시간 엔드포인트(date/datetime)와의 포맷 차이도 분명히 한다.

추가 테스트: 잘못된 포맷 입력 시 CoreException 기반 일관 에러 포맷이 반환되는지 E2E로 검증한다(앞선 E2E 테스트 코멘트와 연계).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java`
around lines 26 - 36, The API spec methods in RankingV1ApiSpec
(getWeeklyRankings and getMonthlyRankings) do not document the expected date
format or requiredness; update the method signatures' OpenAPI metadata by adding
`@Parameter` annotations (io.swagger.v3.oas.annotations.Parameter) to the date
parameter for both getWeeklyRankings and getMonthlyRankings with
description="yyyyMMdd 형식", required=true, and example="20260414" so the
generated OpenAPI clearly shows the format and that the param is mandatory;
ensure imports are added and that the annotation is applied to the date
parameter so clients see the format/requiredness in docs and spec-driven
clients.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java (1)

249-249: 하드코딩된 미래 날짜 "20260414"로 인해 시간 경과 시 테스트 의도가 흐려진다.

운영 관점에서 배치 랭킹 API는 "특정 날짜의 집계 결과"를 반환하므로, 고정 날짜가 현재 시점과 괴리되면 실제 운영과 동떨어진 케이스만 검증하게 되고, 실수로 "오늘 데이터가 없어 빈 배열" 같은 현상도 같은 날짜로 테스트되어 회귀를 놓칠 수 있다. 가능하면 LocalDate.now() 기반으로 동적으로 생성하거나, 고정 과거 날짜(예: "20250101")를 사용해 의미를 분명히 한다.

♻️ 제안 수정안
-        private static final String DATE = "20260414";
+        private static final String DATE = LocalDate.now()
+                .format(DateTimeFormatter.ofPattern("yyyyMMdd"));

이 경우 setUp에서의 LocalDate.of(2026, 4, 14)LocalDate.now()로 맞춰야 한다.

추가 테스트: 잘못된 date 포맷("2026-04-14", "20261340" 등) 입력 시 CoreException을 통해 일관된 에러 응답이 반환되는지 확인하는 케이스를 WeeklyRankings/MonthlyRankings에 추가한다.

Also applies to: 324-324

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java`
at line 249, Replace the hardcoded future DATE constant ("20260414") in
RankingV1ApiE2ETest with a dynamic value (e.g.,
LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) or a clearly past
fixed date like "20250101", and update the setUp code that currently uses
LocalDate.of(2026,4,14) to derive its date from the same source so the test
intent remains consistent; then add extra negative test cases in the
WeeklyRankings/MonthlyRankings tests to assert that invalid date strings (e.g.,
"2026-04-14", "20261340") produce a CoreException and the expected error
response.
apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java (1)

19-25: 행(row)당 1회 upsert로 인한 N회 왕복이 장기적으로 병목이 될 수 있다.

운영 관점에서 현재는 상한 100건이라 문제 없으나, 집계 대상 확장/다중 카테고리 확장 시 네트워크 왕복이 선형 증가한다. 또한 전체를 @Transactional로 감싼 상태에서 개별 native upsert가 flush 시점·자동 커밋/배치 설정에 영향을 받으므로 JDBC batch(rewriteBatchedStatements=true + hibernate.jdbc.batch_size) 활용이나 단일 INSERT ... VALUES (...), (...) ON DUPLICATE KEY UPDATE 쿼리로의 일괄 upsert를 검토한다.

또한 rankingsnull/빈 리스트일 때의 동작(불필요한 트랜잭션 시작)도 가드가 있으면 좋다.

♻️ 제안 수정안(가드 + 확장 포인트)
     `@Override`
     `@Transactional`
     public void saveAll(List<ProductRankingWeekly> rankings) {
+        if (rankings == null || rankings.isEmpty()) {
+            return;
+        }
         rankings.forEach(r ->
                 productRankingWeeklyJpaRepository.upsert(r.getProductId(), r.getScoreDate(), r.getScore())
         );
     }

추가 테스트: 100건 저장 시 발행 쿼리 수를 DataSourceProxy/hibernate.generate_statistics로 확인하는 통합 테스트를 추가해 향후 일괄 upsert 전환의 효과를 회귀로 보장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java`
around lines 19 - 25, saveAll currently performs a per-row upsert via
productRankingWeeklyJpaRepository.upsert in a loop which causes N database
round-trips and unnecessary transactions for null/empty input; add a null/empty
guard for rankings and replace the loop with a batch upsert path (either
implement a new repository method like
productRankingWeeklyJpaRepository.upsertBatch(List<ProductRankingWeekly>) that
executes a single native INSERT ... VALUES (...),(... ) ON DUPLICATE KEY UPDATE
or use JDBC batching by setting rewriteBatchedStatements=true and
hibernate.jdbc.batch_size and using a batched PreparedStatement execution), keep
`@Transactional` on saveAll but delegate to the new batch method, and add an
integration test (using DataSourceProxy or hibernate.generate_statistics) that
asserts the number of executed queries for 100 items to prevent regressions.
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java (1)

8-8: [start, end)인지 [start, end]인지 경계 의미 명시를 권장한다.

운영 관점에서 start/end의 포함/배타 여부는 주간(7일)·월간(30일) 집계 결과에 직접적으로 영향을 미친다. 호출부(WeeklyRankingTasklet/MonthlyRankingTasklet)에서 start = scoreDate.minusDays(6) vs minusDays(7)처럼 off-by-one이 쉽게 발생할 수 있으므로, Javadoc으로 경계 규약(inclusive/exclusive)과 limit의 상한(최대 100) 의미를 명시한다.

추가 테스트: Tasklet 테스트에서 경계일(start, end 당일)의 메트릭이 집계에 포함/제외되는지 명시적으로 단언한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java`
at line 8, Add explicit Javadoc to the repository method findTopScores(LocalDate
start, LocalDate end, int limit) stating whether the date range is
inclusive/exclusive (e.g., [start, end) or [start, end]) and documenting that
limit has a maximum of 100 and its behavior when exceeded; update any callers
(WeeklyRankingTasklet / MonthlyRankingTasklet) to follow that convention and add
unit/integration tests that assert whether metrics on the boundary dates (start
and end) are included or excluded to catch off-by-one mistakes.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java (1)

47-53: 필수 JobParameter date 검증이 Job 레벨에 없어 실패 시점이 늦어진다.

현재 구성은 date가 누락돼도 Job이 기동되어 Tasklet 실행 중 예외가 발생하는 구조로 보인다. 운영에서 스케줄러/CLI 오류로 파라미터가 빠지면, JobRepository에 실패한 JobInstance가 남고 재실행 시 파라미터 키 충돌로 조사 부담이 커진다. DefaultJobParametersValidatordate를 required로 선언해 기동 전에 빠르게 실패시키는 것이 바람직하다.

추가 테스트: MonthlyRankingJobE2ETest에 "date 파라미터 누락 시 JobParametersInvalidException 발생" 케이스 추가를 권고한다.

♻️ 제안 변경
     `@Bean`(JOB_NAME)
     public Job monthlyRankingJob() {
         return new JobBuilder(JOB_NAME, jobRepository)
                 .start(monthlyRankingStep())
                 .listener(jobListener)
+                .validator(new DefaultJobParametersValidator(new String[]{"date"}, new String[]{}))
                 .build();
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java`
around lines 47 - 53, Add a Job-level required-parameter validator so the job
fails fast when the "date" parameter is missing: in the monthlyRankingJob() bean
(where JOB_NAME is built with JobBuilder, jobRepository, monthlyRankingStep(),
jobListener) attach a DefaultJobParametersValidator configured with "date" as a
required key before build() so the job throws JobParametersInvalidException at
startup if date is absent; also add an E2E test in MonthlyRankingJobE2ETest
asserting that launching the job without the date parameter results in
JobParametersInvalidException.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java (1)

57-79: WeeklyRankingTasklet과 구조가 동일하여 유지보수 비용이 이중화된다.

현재 MonthlyRankingTaskletWeeklyRankingTasklet은 window 길이(7/30)와 대상 엔티티/레포지토리 타입만 다르고 실행 흐름(검증 → 파싱 → 집계 → 매핑 → 저장 → 로깅)이 100% 일치한다. 향후 스코어 공식 변경/로깅 추가/관측 지표 삽입 시 두 파일을 동시에 수정해야 하여 불일치 위험이 크다. 제네릭 추상 Tasklet(또는 전략 함수 주입)으로 추출하는 것을 권장한다.

abstract class AbstractRankingTasklet<T> implements Tasklet {
    protected abstract int windowDays();
    protected abstract T toRanking(Long productId, LocalDate baseDate, Double score);
    protected abstract void save(List<T> rankings);
    // execute(...) 공통화
}

추가 테스트로, 동일 date 파라미터로 Weekly/Monthly를 함께 실행했을 때 window 계산이 독립적으로 정확한지(경계일 포함/제외) 파라미터라이즈드 테스트로 보강할 것을 권장한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java`
around lines 57 - 79, MonthlyRankingTasklet duplicates WeeklyRankingTasklet
logic (only window size and target types differ); extract common flow into an
abstract generic tasklet (e.g., AbstractRankingTasklet<T> implements Tasklet)
that defines protected abstract int windowDays(), protected abstract T
toRanking(Long productId, LocalDate baseDate, Double score), and protected
abstract void save(List<T> rankings), move the shared execute(...) logic
(validate date, compute base/start/end using windowDays(), query repository
method call injection or abstract finder, map projections via toRanking(...),
call save(...), and centralized logging) into AbstractRankingTasklet, then make
MonthlyRankingTasklet and WeeklyRankingTasklet subclasses that only implement
windowDays(), toRanking(), save() (and provide repository/finder instances), and
add parameterized tests that run both tasklets with the same date to assert
start/end bounds and saved counts.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java (1)

47-63: native upsert 후 영속성 컨텍스트 동기화가 명시되지 않아 플래키 가능성이 있다.

upsert@Modifying 네이티브 쿼리이므로 clearAutomatically/flushAutomatically가 꺼져 있으면 이전 saveAll 결과가 1차 캐시에 남은 상태에서 두 번째 saveAllfindAll이 수행될 때 동일 키 엔티티가 존재할 경우 구 버전 엔티티가 병합되어 보일 수 있다. 현재 구현(Impl이 upsert만 호출하고 save는 호출하지 않는 경우)에서는 1차 캐시에 관리 대상 엔티티가 없어 괜찮을 가능성이 높지만, 구현 세부사항에 테스트가 묵시적으로 의존하게 된다. 테스트 말미에 entityManager.flush(); entityManager.clear();를 명시하거나 @Transactional을 사용하지 않는 방식을 명시해 운영에서 발생할 동시성/캐시 이슈와 테스트 경로가 일치하도록 하는 것을 권장한다.

추가로, 누적 멱등성(같은 배치를 두 번 돌려도 동일 결과)을 더 강하게 검증하기 위해 saveAll 리스트에 동일 키 두 건을 한 번에 넣는 케이스와, 서로 다른 productId가 섞인 케이스를 추가하면 좋다.

As per coding guidelines (**/*Test*.java): "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java`
around lines 47 - 63, The test ProductRankingMonthlyRepositoryIntegrationTest is
flakey because the repository's native upsert (invoked via
productRankingMonthlyRepository.saveAll) may not synchronize the JPA persistence
context before asserting via productRankingMonthlyJpaRepository.findAll; fix by
explicitly synchronizing the context after the saveAll calls (call
entityManager.flush(); entityManager.clear(); in the test) or make the test
non-transactional so the DB state is read fresh, and while here add two extra
assertions/cases: one where saveAll is given two entries with the same productId
in a single list (to validate in-batch idempotency) and one mixed-productId
batch to ensure unaffected rows remain. Ensure you reference the test method
updatesScore_whenDuplicateKey and update it to flush/clear the EntityManager
before calling findAll.
apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java (1)

38-45: 집계 카운터 기본값/음수 방어가 없다.

viewCount/likeCount/orderCountnullable = false로만 정의되어 있고 생성자/팩토리나 기본값이 없어, 누군가 Reflection/부분 persist 경로로 null을 저장하거나 음수 값이 들어올 경우 집계 스코어(view×0.1 + like×0.2 + order×0.7)가 왜곡되어 랭킹 결과가 깨진다. 도메인 모델로서 최소한 생성 시 0 이상 검증과 기본값(0L)을 명시하여 불변식을 강제하고, 상태 변경 API(예: increaseViewCount)를 통해서만 갱신되도록 캡슐화하는 것을 권장한다.

추가 테스트로 엔티티 생성 시 음수 주입에 대한 검증 실패 케이스, 기본값이 0임을 확인하는 단위 테스트를 추가해야 한다.

As per coding guidelines (**/domain/**/*.java): "엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`
around lines 38 - 45, ProductMetrics currently declares
viewCount/likeCount/orderCount as non-nullable fields but lacks defaults and
validation; add a constructor or static factory in ProductMetrics that
initializes these to 0L, enforce non-negative checks there (throw
IllegalArgumentException on negative input), make any public setters
private/remove direct mutation, and expose controlled mutators such as
increaseViewCount(), increaseLikeCount(), increaseOrderCount() that validate and
update counts atomically; finally add unit tests asserting default values are 0
and that constructing or mutating with negative values fails.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java (1)

86-101: product2의 scoreDate 검증이 누락되었다.

product1은 scoreDate를 확인하지만 product2는 score만 검증하다. 매핑 로직이 전체 결과에 대해 일관되게 동작하는지 확인하려면 product2에도 동일하게 scoreDate 단언을 포함하는 것이 좋다. 대량 저장 시 특정 항목만 scoreDate가 누락/오설정되는 회귀를 잡기 위한 최소한의 안전장치다.

♻️ 제안 수정
                     () -> {
                         ProductRankingWeekly product2 = rankings.stream()
                                 .filter(r -> r.getProductId().equals(2L))
                                 .findFirst().orElseThrow();
                         assertThat(product2.getScore()).isEqualTo(8.1);
+                        assertThat(product2.getScoreDate()).isEqualTo(LocalDate.of(2026, 4, 13));
                     }

As per coding guidelines: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java`
around lines 86 - 101, 테스트에서 product2의 scoreDate 검증이 빠져 있으므로
WeeklyRankingTaskletTest의 assertions 내 두 번째 람다(상품 조회하는 블록)를 수정해
ProductRankingWeekly product2 = ... 뒤에 product2.getScoreDate()에 대한 단언을 추가하여
product1과 동일한 기대값(LocalDate.of(2026, 4, 13) 또는 도메인에서 정한 기대 날짜)을 검증하도록 하세요; 관련
식별자는 rankings, ProductRankingWeekly, getScore(), getScoreDate()입니다.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java (1)

41-86: Upsert 경계 동작에 대한 추가 검증을 권장한다.

현재 테스트는 단일 product에 대한 (1) 동일 키 덮어쓰기와 (2) 다른 score_date 분리만 다룬다. 운영 관점에서 다음 케이스가 함께 검증되면 멱등성/벌크 upsert 버그를 조기에 포착할 수 있다.

  • 서로 다른 productId가 동일 score_date에 섞인 배치의 한 번 saveAll 호출 시, 각 row별로 올바르게 insert/update가 분기되는지(부분 중복 배치).
  • 빈 리스트 saveAll(List.of()) 호출 시 예외 없이 0건으로 종료되는지(스케줄 초기 구동·상위 N이 0건인 엣지 케이스).

이는 네이티브 ON DUPLICATE KEY UPDATE 구문과 파라미터 바인딩에서 흔히 발생하는 회귀를 막는다.

As per coding guidelines: "쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java`
around lines 41 - 86, Add two integration tests to cover the upsert edge cases:
(1) a test that calls ProductRankingWeeklyRepository.saveAll with a mixed list
containing multiple ProductRankingWeekly.create entries having the same
score_date but different productId values and then assert via
productRankingWeeklyJpaRepository.findAll that each productId has its own row
with the correct score (verifying partial updates/inserts), and (2) a test that
calls saveAll with an empty list (e.g., List.of()) and asserts no exception is
thrown and productRankingWeeklyJpaRepository.findAll returns zero changes;
implement these new tests alongside the existing SaveAll nested class to ensure
bulk upsert idempotency and empty-batch handling.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java (1)

117-145: 윈도 경계 데이터에 대한 명시적 검증 부재.

월간 윈도(date=2026041403/15 ~ 04/13)의 경계 데이터(예: 03/14, 03/15, 04/13, 04/14)가 포함/제외되는지 검증하는 케이스가 없다. 운영에서 가장 자주 발생하는 버그가 범위 경계(half-open vs closed) 오류이므로, 적어도 시작·종료 경계 각각 "포함/제외"되어야 하는 날짜 각 1건씩을 시드해 집계 결과로 경계 동작을 고정하는 것이 좋다. 이는 Weekly E2E 테스트에도 동일하게 적용된다.

♻️ 추가 테스트 스케치
// date=20260414, expected window = [2026-03-15, 2026-04-13]
saveMetrics(10L, LocalDate.of(2026, 3, 14), ...); // 제외되어야 함
saveMetrics(11L, LocalDate.of(2026, 3, 15), ...); // 포함
saveMetrics(12L, LocalDate.of(2026, 4, 13), ...); // 포함
saveMetrics(13L, LocalDate.of(2026, 4, 14), ...); // 제외되어야 함
// ... 집계 결과에서 productId 10, 13이 존재하지 않는지 단언

As per coding guidelines: "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java`
around lines 117 - 145, The test is missing explicit boundary-date checks for
the monthly window; update the isIdempotent_whenRerunWithSameParameters test to
seed metrics on the window boundaries using saveMetrics (e.g., dates: start-1,
start, end, end+1 for date="20260414" where expected window is
2026-03-15..2026-04-13) and then assert that only the metrics for the included
boundary dates (start and end) appear in productRankingMonthlyJpaRepository
results while the outside dates (start-1 and end+1) do not; keep the existing
idempotency launches (jobLauncherTestUtils.launchJob with jobParameters1/2) but
extend the arrange and assert sections to check inclusion/exclusion of
productIds for those boundary records.
apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java (2)

95-114: double 값에 대한 정확 일치 단언은 깨지기 쉽다.

isEqualTo(46.5), isEqualTo(8.1)처럼 double을 정확 일치로 비교한다. 지금은 합산 조합이 우연히 정확히 표현 가능한 값들이지만, 집계 로직이 나중에 BigDecimal/가중치 변경 등으로 미세하게 바뀌면 부동소수점 오차로 플래키 실패가 발생한다. 허용 오차 기반 단언으로 고정할 것을 권장한다.

♻️ 제안 수정
-                        assertThat(product1.getScore()).isEqualTo(46.5);
+                        assertThat(product1.getScore()).isCloseTo(46.5, within(1e-6));
...
-                        assertThat(product2.getScore()).isEqualTo(8.1);
+                        assertThat(product2.getScore()).isCloseTo(8.1, within(1e-6));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 95 - 114, Replace exact double equality assertions in
WeeklyRankingJobE2ETest with tolerance-based comparisons: for
ProductRankingWeekly score checks (product1.getScore() and product2.getScore())
use AssertJ's close/offset matcher (e.g., isCloseTo(..., within/offset) or
isCloseTo with Percentage) with a small epsilon (e.g., 1e-6 or 1e-4) instead of
isEqualTo(46.5) and isEqualTo(8.1); keep other assertions (jobExecution exit
status, rankings size, scoreDate) unchanged and update imports if needed.

147-162: saveMetrics 헬퍼가 Monthly E2E 테스트와 중복된다.

WeeklyRankingJobE2ETestMonthlyRankingJobE2ETest에 동일한 reflective 생성자 호출 + 필드 세팅 로직이 그대로 복제되어 있다. ProductMetrics 구조(필드 추가/이름 변경) 변경 시 양쪽 테스트가 같이 깨지고, 한쪽만 고쳐지는 실수가 발생하기 쉽다. 테스트 공용 모듈로 고정 팩토리(또는 ProductMetricsFixture)를 추출하는 것을 권장한다.

또한 운영 관점에서는 ProductMetrics@Builder(또는 패키지 private 정적 팩토리)를 테스트용으로 열어두면 리플렉션 의존을 제거할 수 있다. 리플렉션 기반 생성은 필드 rename 시 컴파일 타임에 감지되지 않는 취약점이 있다.

♻️ 공용 Fixture 예시
// apps/commerce-batch/src/testFixtures/.../ProductMetricsFixture.java 등
public final class ProductMetricsFixture {
    private ProductMetricsFixture() {}

    public static ProductMetrics of(Long productId, LocalDate metricDate,
                                    Long viewCount, Long likeCount, Long orderCount) {
        try {
            var ctor = ProductMetrics.class.getDeclaredConstructor();
            ctor.setAccessible(true);
            ProductMetrics m = ctor.newInstance();
            ReflectionTestUtils.setField(m, "productId", productId);
            ReflectionTestUtils.setField(m, "metricDate", metricDate);
            ReflectionTestUtils.setField(m, "viewCount", viewCount);
            ReflectionTestUtils.setField(m, "likeCount", likeCount);
            ReflectionTestUtils.setField(m, "orderCount", orderCount);
            return m;
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("ProductMetrics fixture 생성 실패", e);
        }
    }
}

As per coding guidelines: "Java 테스트 리뷰 기준이다. ... 통합 테스트는 ... 테스트 데이터 준비/정리 전략을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`
around lines 147 - 162, The saveMetrics helper in WeeklyRankingJobE2ETest
duplicates reflection-based construction used in MonthlyRankingJobE2ETest;
extract that logic into a shared test fixture (e.g.,
ProductMetricsFixture.of(...)) and replace both saveMetrics/inline reflective
blocks to call ProductMetricsFixture.of(productId, metricDate, viewCount,
likeCount, orderCount) then persist via productMetricsJpaRepository.save(...);
alternatively, if feasible add a package-private static factory or `@Builder` to
ProductMetrics and update both tests to use that instead of ReflectionTestUtils
to remove fragile reflection usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java`:
- Line 36: The LocalDate.parse call in ReadMonthlyRankingsUseCase is allowing
DateTimeParseException to bubble up and produce a 500; catch
DateTimeParseException around LocalDate.parse(date, DATE_FORMAT) (and similarly
in ReadWeeklyRankingsUseCase) and instead throw a CoreException with
ErrorType.INVALID_RANKING_DATE_FORMAT, preserving the original exception as the
cause and separating user message from log message; better yet, move parsing
into a shared helper (e.g., RankingKeyResolver.parseDate(...)) that performs
this catch-and-wrap behavior so both weekly/monthly use it, and add an E2E test
in RankingV1ApiE2ETest that requests date=invalid and asserts a 400 with
INVALID_RANKING_DATE_FORMAT.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java`:
- Around line 35-38: The execute method in ReadWeeklyRankingsUseCase parses the
date with LocalDate.parse which can throw DateTimeParseException and bypass API
error normalization; wrap the parse in a try/catch that catches
DateTimeParseException and rethrow a CoreException with BAD_REQUEST (reuse the
message/format style used by RankingKeyResolver.validateDateFormat), then
proceed to call rankingService.readWeeklyTopRanked as before; apply the
identical change to the monthly use case (MonthlyReadRankingsUseCase / similar
execute method) and add an E2E test in RankingV1ApiE2ETest asserting that
invalid date formats (e.g., "invalid", "2026-04-14") return the standardized 4xx
CoreException error response.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java`:
- Around line 22-49: ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingRepositoryImpl.java`:
- Around line 26-30: Remove the infrastructure-layer transaction annotation by
deleting the `@Transactional`(readOnly = true) on the
MonthlyRankingRepositoryImpl#readTopRanked method; leave the method as a plain
repository implementation that delegates to
monthlyRankingJpaRepository.findByScoreDateOrderByScoreDesc(...). Ensure
transaction boundaries are enforced at the application layer (verify
ReadMonthlyRankingsUseCase or its ApplicationService is annotated with
`@Transactional`(readOnly = true) and add it there if missing), and run regression
tests to confirm persistence-context reads work as expected.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImpl.java`:
- Around line 26-30: Remove the redundant `@Transactional` on
WeeklyRankingRepositoryImpl.readTopRanked: delete the `@Transactional`(readOnly =
true) annotation from the readTopRanked method in WeeklyRankingRepositoryImpl so
transaction boundaries are not set in the infrastructure layer (leave the call
to weeklyRankingJpaRepository.findByScoreDateOrderByScoreDesc as-is); ensure
transaction management is handled in the application layer (e.g.,
ReadWeeklyRankingsUseCase) and add/extend an integration test that verifies
ReadWeeklyRankingsUseCase is annotated with `@Transactional`(readOnly = true) (or
otherwise executes reads within a single read-only transaction) and returns
identical results.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1Api.java`:
- Around line 57-75: getWeeklyRankings and getMonthlyRankings declare
`@RequestParam` String date (required=true) while getDailyRankings uses
`@RequestParam`(required = false) String date, causing inconsistent behavior and
potential MissingServletRequestParameterException; make the endpoints consistent
by either: (A) marking date optional for getWeeklyRankings/getMonthlyRankings
(use `@RequestParam`(required = false) String date) and delegating a null/blank
date to the corresponding ReadWeeklyRankingsUseCase/ReadMonthlyRankingsUseCase
to default to today, or (B) keep them required but explicitly catch/convert the
missing-parameter and parse errors into your CoreException type within
RankingV1Api (or inside ReadMonthlyRankingsUseCase/ReadWeeklyRankingsUseCase) so
ApiControllerAdvice emits the unified 4xx payload; also add E2E tests in
RankingV1ApiE2ETest for (1) missing date, (2) invalid format, (3) future date to
validate the behavior and ensure PageSize.withMaxSize usage remains unchanged.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java`:
- Around line 278-306: Add a positive-liked test similar to the existing
returnsRankedProducts: in RankingV1ApiE2ETest create/login a test user, issue a
like for a specific productId via the same service/client used in other tests,
call getWeeklyRankings (and getMonthlyRankings analogously) with the
authenticated testRestTemplate (or include the auth token), then assert that the
returned RankingDto.RankedProductResponse for that productId has liked() ==
true; place this alongside/analogous to returnsRankedProducts and
supportsPagination so it uses getWeeklyRankings/getMonthlyRankings,
productId1/productId2/productId3, and the existing assertion patterns.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java`:
- Around line 62-64: MonthlyRankingTasklet currently calls LocalDate.parse(date,
DATE_FORMAT) which lets DateTimeParseException escape; catch
DateTimeParseException around the parse in MonthlyRankingTasklet (and mirror the
same change in WeeklyRankingTasklet), throw a new IllegalArgumentException with
a clear user-facing message that includes the supplied date string (ensure it’s
not sensitive) and pass the caught exception as the cause to preserve
stacktrace, and log a separate detailed debug/error log message containing the
parse exception; also add two unit tests in MonthlyRankingTaskletTest for
malformed inputs ("20260230" and "2026-04-14") asserting an
IllegalArgumentException is thrown and the message contains the invalid input.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductScoreProjection.java`:
- Line 3: Change the nullable wrapper type to a primitive: update
ProductScoreProjection (record ProductScoreProjection(Long productId, Double
score)) to use a primitive double for score and make the same change in
ProductRankingWeekly and ProductRankingMonthly to reflect non-nullable, non-null
`@Column`(nullable=false) semantics; then add integration tests in
ProductMetricsRepositoryImpl that cover boundary cases: (1) a product with
metrics only for a subset of the aggregation period (partial presence) and (2) a
product whose all counts are zero to assert SUM(...) yields 0.0 and mappings to
the primitive score behave correctly. Ensure you update any
constructors/mappers/usages that expect Double to use double and adjust
assertions in the new tests to check for exact 0.0 where appropriate.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java`:
- Around line 17-44: ProductRankingWeekly lacks equals/hashCode and has an
`@UniqueConstraint` that may be only documentation: either implement
identifier-based equals/hashCode (e.g., using id when non-null, otherwise
fallback to business key productId+scoreDate) on ProductRankingWeekly to make
behavior explicit for future Set/Map use, or add a clear class-level comment
stating equals/hashCode are intentionally omitted; additionally, verify that the
`@UniqueConstraint` on the entity is documented as non-enforcing when ddl-auto is
disabled and confirm the actual unique constraint exists in your external
migration scripts and is consistent with other modules' entity definitions (API
module) so schema/behavior remain synchronized.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java`:
- Around line 16-27: The weighted-sum expression is duplicated in the JPQL
SELECT and ORDER BY inside ProductMetricsJpaRepository.findTopScores, risking
inconsistency when tuning weights; fix by centralizing the aggregation: either
repeat the exact same SUM(...) expression in both the constructor expression and
ORDER BY (ensure the numeric literals 0.1, 0.2, 0.7 remain identical), or
replace the JPQL query with a CriteriaQuery or native SQL that computes the
weighted score once and maps it to
com.loopers.domain.metrics.ProductScoreProjection; update findTopScores to use
the chosen approach and add a unit test that verifies ordering/tie-break
behavior when weights change.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java`:
- Around line 14-26: The native upsert method
ProductRankingWeeklyJpaRepository.upsert is annotated with `@Modifying` but lacks
flushAutomatically/clearAutomatically, which can leave the persistence context
stale after the native INSERT ... ON DUPLICATE KEY UPDATE; update the `@Modifying`
on ProductRankingWeeklyJpaRepository.upsert (and mirror in
ProductRankingMonthlyJpaRepository.upsert) to include flushAutomatically = true
and clearAutomatically = true so the EntityManager is flushed and the
persistence context cleared after the native call, and add a unit/integration
test that performs saveAll → upsert → immediate read within the same transaction
to assert the returned score is the updated value; also add a TODO note to
replace per-row upsert with multi-row VALUES or JDBC batch in future to avoid N
round-trips.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java`:
- Around line 49-60: Add boundary tests to MonthlyRankingTaskletTest to cover
empty, whitespace, and malformed date strings: create new `@Test` methods that use
ReflectionTestUtils.setField(tasklet, "date", "") and setField(..., "  ") and
setField(..., "2026-04-14") / "20260230", then call
tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); assert
that empty and whitespace date inputs throw IllegalArgumentException
(hasMessageContaining "date") and that malformed format inputs propagate
DateTimeParseException from LocalDate.parse; reference
MonthlyRankingTaskletTest, tasklet.execute, ReflectionTestUtils.setField, and
LocalDate.parse when adding these assertions.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 61-73: The failing test failsJob_whenDateParameterIsMissing uses
jobLauncherTestUtils.launchJob() with empty JobParameters causing
JobInstanceAlreadyCompleteException due to leftover Spring Batch meta tables;
fix it by launching the job with a unique run.id JobParameter (same pattern used
in isIdempotent_whenRerunWithSameParameters()) so the job instance is unique and
the test exercises the missing date validation path—update the test to build
JobParameters including a generated run.id (or timestamp) before calling
jobLauncherTestUtils.launchJob(), or alternatively ensure
DatabaseCleanUp.truncateAllTables() also clears Spring Batch meta tables
(BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION) if you prefer cleanup approach.

---

Outside diff comments:
In
`@apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`:
- Around line 22-39: ProductMetrics' metric_date can drift between DB CURDATE()
and JVM LocalDate; fix by either specifying serverTimezone (e.g.,
serverTimezone=Asia/Seoul or =UTC) in the application's JDBC URL so DB CURDATE()
and the app JVM share the same TZ, or change the streamer upsert to stop using
CURDATE() and instead pass an application-computed LocalDate parameter into the
upsert (affecting the code that writes metric_date in the streamer/upsert SQL);
additionally add an integration test that injects events at 23:59:59 and
00:00:00 and asserts ProductMetrics entries (productId/metricDate) are recorded
into the same expected date bucket to prevent midnight-boundary mismatches.

---

Duplicate comments:
In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/ranking/persistence/WeeklyRankingRepositoryImplIntegrationTest.java`:
- Around line 77-85: 테스트 격리 문제: WeeklyRankingRepositoryImplIntegrationTest의
returnsEmptyList_whenNoData가 다른 테스트가 같은 SCORE_DATE로 mv_product_rank_weekly에 남긴
데이터 때문에 순서에 따라 실패할 수 있으니, 테스트를 격리하도록 수정하세요; 해결 방법은 (1)
returnsEmptyList_whenNoData 또는 해당 클래스의 테스트에서 readTopRanked 호출에 사용하는 SCORE_DATE를
고유한 값으로 변경하거나 (2) WeeklyRankingRepositoryImplIntegrationTest에 `@BeforeEach` 메서드를
추가해 mv_product_rank_weekly에서 SCORE_DATE 관련 레코드를 삭제하도록 정리하는 것입니다 — 참고로 영향을 주는 심볼:
returnsEmptyList_whenNoData, SCORE_DATE, readTopRanked,
WeeklyRankingRepositoryImplIntegrationTest, BaseIntegrationTest,
mv_product_rank_weekly.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/persistence/MonthlyRankingJpaRepository.java`:
- Around line 11-13: Add an integration test that verifies the derived query in
MonthlyRankingJpaRepository (findByScoreDateOrderByScoreDesc) actually uses the
idx_score_date_score(index on score_date, score DESC) and does not trigger a
filesort for large datasets: execute an EXPLAIN for the SQL generated by
findByScoreDateOrderByScoreDesc against a populated test table
(ProductRankingMonthly) with realistic volume and assert the EXPLAIN output
shows the index is used and no "filesort" appears; include test setup using
Pageable with PageSize.withMaxSize() to ensure page size limits and run this as
part of CI so index usage is validated automatically.

In
`@apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiSpec.java`:
- Around line 26-36: The API spec methods in RankingV1ApiSpec (getWeeklyRankings
and getMonthlyRankings) do not document the expected date format or
requiredness; update the method signatures' OpenAPI metadata by adding
`@Parameter` annotations (io.swagger.v3.oas.annotations.Parameter) to the date
parameter for both getWeeklyRankings and getMonthlyRankings with
description="yyyyMMdd 형식", required=true, and example="20260414" so the
generated OpenAPI clearly shows the format and that the param is mandatory;
ensure imports are added and that the annotation is applied to the date
parameter so clients see the format/requiredness in docs and spec-driven
clients.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/v1/RankingV1ApiE2ETest.java`:
- Line 249: Replace the hardcoded future DATE constant ("20260414") in
RankingV1ApiE2ETest with a dynamic value (e.g.,
LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)) or a clearly past
fixed date like "20250101", and update the setUp code that currently uses
LocalDate.of(2026,4,14) to derive its date from the same source so the test
intent remains consistent; then add extra negative test cases in the
WeeklyRankings/MonthlyRankings tests to assert that invalid date strings (e.g.,
"2026-04-14", "20261340") produce a CoreException and the expected error
response.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java`:
- Around line 47-53: Add a Job-level required-parameter validator so the job
fails fast when the "date" parameter is missing: in the monthlyRankingJob() bean
(where JOB_NAME is built with JobBuilder, jobRepository, monthlyRankingStep(),
jobListener) attach a DefaultJobParametersValidator configured with "date" as a
required key before build() so the job throws JobParametersInvalidException at
startup if date is absent; also add an E2E test in MonthlyRankingJobE2ETest
asserting that launching the job without the date parameter results in
JobParametersInvalidException.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/MonthlyRankingTasklet.java`:
- Around line 57-79: MonthlyRankingTasklet duplicates WeeklyRankingTasklet logic
(only window size and target types differ); extract common flow into an abstract
generic tasklet (e.g., AbstractRankingTasklet<T> implements Tasklet) that
defines protected abstract int windowDays(), protected abstract T toRanking(Long
productId, LocalDate baseDate, Double score), and protected abstract void
save(List<T> rankings), move the shared execute(...) logic (validate date,
compute base/start/end using windowDays(), query repository method call
injection or abstract finder, map projections via toRanking(...), call
save(...), and centralized logging) into AbstractRankingTasklet, then make
MonthlyRankingTasklet and WeeklyRankingTasklet subclasses that only implement
windowDays(), toRanking(), save() (and provide repository/finder instances), and
add parameterized tests that run both tasklets with the same date to assert
start/end bounds and saved counts.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java`:
- Around line 38-45: ProductMetrics currently declares
viewCount/likeCount/orderCount as non-nullable fields but lacks defaults and
validation; add a constructor or static factory in ProductMetrics that
initializes these to 0L, enforce non-negative checks there (throw
IllegalArgumentException on negative input), make any public setters
private/remove direct mutation, and expose controlled mutators such as
increaseViewCount(), increaseLikeCount(), increaseOrderCount() that validate and
update counts atomically; finally add unit tests asserting default values are 0
and that constructing or mutating with negative values fails.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java`:
- Line 8: Add explicit Javadoc to the repository method findTopScores(LocalDate
start, LocalDate end, int limit) stating whether the date range is
inclusive/exclusive (e.g., [start, end) or [start, end]) and documenting that
limit has a maximum of 100 and its behavior when exceeded; update any callers
(WeeklyRankingTasklet / MonthlyRankingTasklet) to follow that convention and add
unit/integration tests that assert whether metrics on the boundary dates (start
and end) are included or excluded to catch off-by-one mistakes.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeeklyRepository.java`:
- Around line 11-23: The Javadoc on ProductRankingWeeklyRepository currently
mentions MySQL-specific syntax; change the doc on the interface
(ProductRankingWeeklyRepository and its saveAll(List<ProductRankingWeekly>
rankings) method) to describe only the intent: that saveAll performs an
idempotent upsert (updates score when a (productId, scoreDate) exists, otherwise
creates), and remove any reference to MySQL or ON DUPLICATE KEY UPDATE; move any
implementation-specific notes about using MySQL upsert syntax into the
infrastructure layer (e.g., ProductRankingWeeklyJpaRepository or its Impl)
Javadoc or implementation comments.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingMonthlyRepositoryImpl.java`:
- Around line 19-25: saveAll currently iterates and calls
productRankingMonthlyJpaRepository.upsert per item causing N round-trips;
replace this with a single batch upsert: add a new method (e.g., upsertAll) in
ProductRankingMonthlyRepositoryImpl that uses JdbcTemplate (or
JdbcTemplate.batchUpdate) to execute one multi-row "INSERT ... VALUES
(...),(...)" with "ON DUPLICATE KEY UPDATE score=VALUES(score)" for all
List<ProductRankingMonthly> entries (or build and execute a single SQL string
with bound params), stop using per-item
productRankingMonthlyJpaRepository.upsert in saveAll and delegate to upsertAll,
and add an integration test (e.g., `@DataJpaTest` using Hibernate Statistics or
P6Spy/datasource-proxy) that persists ~100 ProductRankingMonthly and asserts the
number of executed SQL statements is 1–2.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java`:
- Around line 19-25: saveAll currently performs a per-row upsert via
productRankingWeeklyJpaRepository.upsert in a loop which causes N database
round-trips and unnecessary transactions for null/empty input; add a null/empty
guard for rankings and replace the loop with a batch upsert path (either
implement a new repository method like
productRankingWeeklyJpaRepository.upsertBatch(List<ProductRankingWeekly>) that
executes a single native INSERT ... VALUES (...),(... ) ON DUPLICATE KEY UPDATE
or use JDBC batching by setting rewriteBatchedStatements=true and
hibernate.jdbc.batch_size and using a batched PreparedStatement execution), keep
`@Transactional` on saveAll but delegate to the new batch method, and add an
integration test (using DataSourceProxy or hibernate.generate_statistics) that
asserts the number of executed queries for 100 items to prevent regressions.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingJobE2ETest.java`:
- Around line 117-145: The test is missing explicit boundary-date checks for the
monthly window; update the isIdempotent_whenRerunWithSameParameters test to seed
metrics on the window boundaries using saveMetrics (e.g., dates: start-1, start,
end, end+1 for date="20260414" where expected window is 2026-03-15..2026-04-13)
and then assert that only the metrics for the included boundary dates (start and
end) appear in productRankingMonthlyJpaRepository results while the outside
dates (start-1 and end+1) do not; keep the existing idempotency launches
(jobLauncherTestUtils.launchJob with jobParameters1/2) but extend the arrange
and assert sections to check inclusion/exclusion of productIds for those
boundary records.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingMonthlyRepositoryIntegrationTest.java`:
- Around line 47-63: The test ProductRankingMonthlyRepositoryIntegrationTest is
flakey because the repository's native upsert (invoked via
productRankingMonthlyRepository.saveAll) may not synchronize the JPA persistence
context before asserting via productRankingMonthlyJpaRepository.findAll; fix by
explicitly synchronizing the context after the saveAll calls (call
entityManager.flush(); entityManager.clear(); in the test) or make the test
non-transactional so the DB state is read fresh, and while here add two extra
assertions/cases: one where saveAll is given two entries with the same productId
in a single list (to validate in-batch idempotency) and one mixed-productId
batch to ensure unaffected rows remain. Ensure you reference the test method
updatesScore_whenDuplicateKey and update it to flush/clear the EntityManager
before calling findAll.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/ProductRankingWeeklyRepositoryIntegrationTest.java`:
- Around line 41-86: Add two integration tests to cover the upsert edge cases:
(1) a test that calls ProductRankingWeeklyRepository.saveAll with a mixed list
containing multiple ProductRankingWeekly.create entries having the same
score_date but different productId values and then assert via
productRankingWeeklyJpaRepository.findAll that each productId has its own row
with the correct score (verifying partial updates/inserts), and (2) a test that
calls saveAll with an empty list (e.g., List.of()) and asserts no exception is
thrown and productRankingWeeklyJpaRepository.findAll returns zero changes;
implement these new tests alongside the existing SaveAll nested class to ensure
bulk upsert idempotency and empty-batch handling.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingJobE2ETest.java`:
- Around line 95-114: Replace exact double equality assertions in
WeeklyRankingJobE2ETest with tolerance-based comparisons: for
ProductRankingWeekly score checks (product1.getScore() and product2.getScore())
use AssertJ's close/offset matcher (e.g., isCloseTo(..., within/offset) or
isCloseTo with Percentage) with a small epsilon (e.g., 1e-6 or 1e-4) instead of
isEqualTo(46.5) and isEqualTo(8.1); keep other assertions (jobExecution exit
status, rankings size, scoreDate) unchanged and update imports if needed.
- Around line 147-162: The saveMetrics helper in WeeklyRankingJobE2ETest
duplicates reflection-based construction used in MonthlyRankingJobE2ETest;
extract that logic into a shared test fixture (e.g.,
ProductMetricsFixture.of(...)) and replace both saveMetrics/inline reflective
blocks to call ProductMetricsFixture.of(productId, metricDate, viewCount,
likeCount, orderCount) then persist via productMetricsJpaRepository.save(...);
alternatively, if feasible add a package-private static factory or `@Builder` to
ProductMetrics and update both tests to use that instead of ReflectionTestUtils
to remove fragile reflection usage.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/WeeklyRankingTaskletTest.java`:
- Around line 86-101: 테스트에서 product2의 scoreDate 검증이 빠져 있으므로
WeeklyRankingTaskletTest의 assertions 내 두 번째 람다(상품 조회하는 블록)를 수정해
ProductRankingWeekly product2 = ... 뒤에 product2.getScoreDate()에 대한 단언을 추가하여
product1과 동일한 기대값(LocalDate.of(2026, 4, 13) 또는 도메인에서 정한 기대 날짜)을 검증하도록 하세요; 관련
식별자는 rankings, ProductRankingWeekly, getScore(), getScoreDate()입니다.

In
`@apps/commerce-streamer/src/test/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsRepositoryImplTest.java`:
- Line 35: CURRENT_METRIC_DATE is set at class load via LocalDate.now() which
can drift across midnight and different timezones causing flaky tests; replace
uses of the static CURRENT_METRIC_DATE with a call to a helper method
currentMetricDate() so the test computes the expected metric date at assertion
time (matching the upsertXxx decision), and update tests to use a fixed Clock or
inject the same Clock used by the production upsert logic (prefer refactoring
production code to accept a java.time.Clock) so both test and upsertXxx compute
metricDate from the same time source.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

* @return 랭킹 페이지 결과 (상품 정보, brandId, 좋아요 여부 포함)
*/
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

잘못된 date 입력 시 500으로 전파된다.

LocalDate.parse(date, DATE_FORMAT)에서 던지는 DateTimeParseException이 어디에서도 잡히지 않아, ApiControllerAdvice를 통해 INTERNAL_ERROR(500)로 응답된다. ErrorType.INVALID_RANKING_DATE_FORMAT(400)이 이미 정의되어 있고 daily/hourly 측은 RankingKeyResolver에서 CoreException으로 변환해 400을 반환하는데, 신규 weekly/monthly 경로만 400이 아닌 500을 반환하게 되어 클라이언트 오용을 장애로 오인하게 된다. 동일한 문제는 ReadWeeklyRankingsUseCase에도 존재할 것으로 보이므로 공통 유틸(예: RankingKeyResolver.parseDate(...))로 일원화하는 것을 권장한다.

추가 테스트로 RankingV1ApiE2ETestdate=invalid로 요청 시 400과 INVALID_RANKING_DATE_FORMAT 코드가 반환되는 케이스를 추가해야 한다.

♻️ 수정안 예시
     public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
-        LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        LocalDate scoreDate;
+        try {
+            scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        } catch (DateTimeParseException e) {
+            throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT);
+        }
         List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size());

As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다." 또한 retrieved learnings("enforce unified error handling by routing errors through CoreException to ApiControllerAdvice")에 따라 도메인 오류를 CoreException으로 일관되게 반환해야 한다.

📝 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
LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate;
try {
scoreDate = LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.INVALID_RANKING_DATE_FORMAT);
}
List<ProductRankingMonthly> rankings = rankingService.readMonthlyTopRanked(scoreDate, pageSize.page(), pageSize.size());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadMonthlyRankingsUseCase.java`
at line 36, The LocalDate.parse call in ReadMonthlyRankingsUseCase is allowing
DateTimeParseException to bubble up and produce a 500; catch
DateTimeParseException around LocalDate.parse(date, DATE_FORMAT) (and similarly
in ReadWeeklyRankingsUseCase) and instead throw a CoreException with
ErrorType.INVALID_RANKING_DATE_FORMAT, preserving the original exception as the
cause and separating user message from log message; better yet, move parsing
into a shared helper (e.g., RankingKeyResolver.parseDate(...)) that performs
this catch-and-wrap behavior so both weekly/monthly use it, and add an E2E test
in RankingV1ApiE2ETest that requests date=invalid and asserts a 400 with
INVALID_RANKING_DATE_FORMAT.

Comment on lines +35 to +38
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.size());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

LocalDate.parse 실패가 CoreException으로 래핑되지 않아 응답 일관성이 깨진다.

잘못된 date 쿼리 파라미터(예: date=2026-04-14, date=abcd)가 들어오면 DateTimeParseException이 그대로 전파되어 ApiControllerAdvice의 표준 에러 포맷을 벗어난 500 응답이 반환될 위험이 있다. 리포지토리 학습에 따르면 commerce-api는 모든 에러를 CoreException 경로로 통일하도록 규약되어 있다(RankingKeyResolver의 validateDateFormat 참고).

운영 관점에서 API 에러 응답 스키마 불일치는 클라이언트 처리 분기를 늘리고 SLO 지표에도 오탐을 만든다. 주간/월간 UseCase 모두 RankingKeyResolver.validateDateFormat류 검증을 재사용하거나, try/catch (DateTimeParseException)CoreException(BAD_REQUEST, ...) 래핑을 추가해야 한다. 루트 원인이 동일하므로 Monthly UseCase에도 동일 수정이 필요하다.

추가 테스트: RankingV1ApiE2ETest에 잘못된 date 포맷(예: "invalid", "2026-04-14")에 대해 표준 에러 포맷(4xx)을 반환하는 케이스를 추가하기를 권고한다.

🛡️ 제안 수정
     public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
-        LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        LocalDate scoreDate;
+        try {
+            scoreDate = LocalDate.parse(date, DATE_FORMAT);
+        } catch (DateTimeParseException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
+        }
         List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(

Based on learnings: "loop-pack-be-l2-vol3-java 프로젝트에서 에러 처리는 CoreException을 통해 ApiControllerAdvice로 라우팅하도록 통일한다."

📝 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 RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate = LocalDate.parse(date, DATE_FORMAT);
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.size());
public RankingPageResult execute(Long userId, String date, PageSize pageSize) {
LocalDate scoreDate;
try {
scoreDate = LocalDate.parse(date, DATE_FORMAT);
} catch (DateTimeParseException e) {
throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 한다.");
}
List<ProductRankingWeekly> rankings = rankingService.readWeeklyTopRanked(
scoreDate, pageSize.page(), pageSize.size());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/ranking/ReadWeeklyRankingsUseCase.java`
around lines 35 - 38, The execute method in ReadWeeklyRankingsUseCase parses the
date with LocalDate.parse which can throw DateTimeParseException and bypass API
error normalization; wrap the parse in a try/catch that catches
DateTimeParseException and rethrow a CoreException with BAD_REQUEST (reuse the
message/format style used by RankingKeyResolver.validateDateFormat), then
proceed to call rankingService.readWeeklyTopRanked as before; apply the
identical change to the monthly use case (MonthlyReadRankingsUseCase / similar
execute method) and add an E2E test in RankingV1ApiE2ETest asserting that
invalid date formats (e.g., "invalid", "2026-04-14") return the standardized 4xx
CoreException error response.

Comment on lines +22 to +49
@Entity
@Table(name = "mv_product_rank_monthly", indexes = {
@Index(name = "idx_score_date_score", columnList = "score_date, score DESC")
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ProductRankingMonthly {

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private LocalDate scoreDate;

@Column(nullable = false)
private Double score;

public static ProductRankingMonthly create(Long productId, LocalDate scoreDate, Double score) {
ProductRankingMonthly ranking = new ProductRankingMonthly();
ranking.productId = productId;
ranking.scoreDate = scoreDate;
ranking.score = score;
return ranking;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

읽기 전용 뷰 엔티티의 쓰기 경로 차단이 필요하다.

본 엔티티는 "배치가 집계한 mv_product_rank_monthly 테이블의 읽기 전용 매핑"으로 문서화되어 있지만, MonthlyRankingJpaRepositoryJpaRepository<ProductRankingMonthly, Long>를 상속하여 save, saveAll, delete, deleteAll 등 쓰기 메서드가 전부 노출된다. 운영 관점에서 다음 리스크가 있다:

  • API 모듈에서 실수로 save(...)를 호출하면 배치가 관리하는 테이블 상태가 오염될 수 있다(동일 (product_id, score_date) 유니크 제약이 본 엔티티에는 빠져 있어 중복 insert 시 원인 파악도 어려워진다).
  • 도메인 문서(읽기 전용)와 실제 노출 API 간 계약이 불일치한다.

수정안으로는 다음 중 하나를 권장한다:

  1. MonthlyRankingJpaRepositoryJpaRepository 대신 Repository<ProductRankingMonthly, Long>(또는 커스텀 최소 인터페이스)로 변경해 쓰기 메서드를 노출하지 않는다.
  2. 엔티티에 @Immutable(Hibernate) 또는 @Entity(..., readOnly=true)에 준하는 제약을 걸고, 세터/팩토리 노출을 축소한다.
  3. 배치 모듈의 엔티티와 동일하게 API 모듈 엔티티에도 (product_id, score_date) 유니크 제약을 선언해 스키마 계약을 일치시킨다(현재 배치 모듈 ProductRankingMonthly에는 유니크 제약이 있고, API 모듈에는 없다).

추가 테스트로, MonthlyRankingJpaRepository의 쓰기 경로가 허용되지 않음을 확인하는 아키텍처 테스트(ArchUnit 등) 또는 리포지토리 계약 테스트를 제안한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java`
around lines 22 - 49, ProductRankingMonthly is mapped as a read-only view but
MonthlyRankingJpaRepository currently extends JpaRepository, exposing write
APIs; update the code so write paths are blocked: change
MonthlyRankingJpaRepository to extend Spring’s Repository<ProductRankingMonthly,
Long> or a custom read-only repository interface (removing
save/saveAll/delete/deleteAll signatures), and/or annotate ProductRankingMonthly
with a Hibernate `@Immutable` (or equivalent readOnly mapping) and tighten
visibility of mutators/factory if needed; also add the (product_id, score_date)
unique constraint on ProductRankingMonthly to match the batch module and add an
architecture or repository contract test (ArchUnit or similar) asserting that
MonthlyRankingJpaRepository does not expose write methods.

Comment on lines +17 to +44
@Entity
@Table(name = "mv_product_rank_weekly", uniqueConstraints = {
@UniqueConstraint(columnNames = {"product_id", "score_date"})
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ProductRankingWeekly {

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

@Column(nullable = false)
private Long productId;

@Column(nullable = false)
private LocalDate scoreDate;

@Column(nullable = false)
private Double score;

public static ProductRankingWeekly create(Long productId, LocalDate scoreDate, Double score) {
ProductRankingWeekly ranking = new ProductRankingWeekly();
ranking.productId = productId;
ranking.scoreDate = scoreDate;
ranking.score = score;
return ranking;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# naming-strategy 설정과 실제 DDL의 컬럼명 일치 여부 확인
rg -nP '(physical-naming-strategy|implicit-naming-strategy|hibernate\.physical_naming_strategy)' --type=yaml --type=properties --type=java
fd -t f 'mv_product_rank_weekly|mv_product_rank_monthly' -e sql | xargs -I{} cat {}
rg -nP 'mv_product_rank_(weekly|monthly)' -C3

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 13478


🏁 Script executed:

# DB 마이그레이션 스크립트 찾기
find . -name "*.sql" -type f | head -20

# mv_product_rank_weekly 테이블 정의 찾기
find . -name "*.sql" -type f -exec grep -l "mv_product_rank_weekly" {} \;

# ProductRankingWeekly가 Set/Map에 사용되는지 확인
rg -n 'Set<ProductRankingWeekly>|Map.*ProductRankingWeekly' --type java

# batch job에서 엔티티 컬렉션 처리 방식 확인
rg -n 'ProductRankingWeekly' --type java -C2 | grep -E '(saveAll|Set|Map|HashSet|HashMap)'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1299


🏁 Script executed:

# 전체 SQL 파일 찾기 및 내용 확인
find . -name "*.sql" -type f

# batch 모듈의 ProductRankingWeekly 정의 확인
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java

# API 모듈의 ProductRankingWeekly 정의 확인 (있다면)
if [ -f "apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java" ]; then
  cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java
else
  echo "API 모듈에는 ProductRankingWeekly 없음"
fi

# Repository 실제 구현 확인
cat apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyRepositoryImpl.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 3364


🏁 Script executed:

# 배치 모듈 ProductRankingMonthly도 확인 (비슷한 패턴인지)
cat apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java

# API 모듈 ProductRankingMonthly도 확인
cat apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingMonthly.java

# 두 모듈 간 entity 정의 차이 정리
echo "=== Comparing batch vs API ProductRankingWeekly ==="
diff -u <(sed -n '/^@Entity/,/^}/p' apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | head -25) <(sed -n '/^@Entity/,/^}/p' apps/commerce-api/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java | head -25)

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 2799


엔티티의 equals/hashCode 구현 명시를 고려하고, JPA 제약 선언의 실효성을 확인한다.

  1. equals/hashCode 구현 부재에 대한 안내: 현재 배치 집계 코드에서는 ProductRankingWeeklyList.saveAll(List<ProductRankingWeekly>) 형태로만 사용하므로 해시 기반 컬렉션(Set/Map)의 키로 사용되지 않는다. 그러나 향후 코드 변경(예: 중복 제거, 캐싱)에서 Set/Map 사용이 발생할 가능성이 있으므로, 현재 의도를 명확히 하기 위해 의도적 미구현이라면 주석으로 표기하거나, 식별자 기반 equals/hashCode를 구현하는 것을 권장한다.

  2. JPA 제약 선언의 실효성 확인: 이 레포지토리는 외부 DB 마이그레이션 스크립트로 스키마를 관리하므로(ddl-auto 미사용), @UniqueConstraint 선언이 실제 데이터베이스 제약을 생성하지 않는다. 따라서 해당 어노테이션은 의도 문서화 역할만 하며, 실제 유니크 제약은 외부 마이그레이션 스크립트가 보장해야 한다. 배치 모듈의 ProductRankingWeekly에만 @UniqueConstraint가 있고 API 모듈 버전에는 없는 점을 고려하여, 엔티티 정의 간 일관성을 검토한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/domain/ranking/ProductRankingWeekly.java`
around lines 17 - 44, ProductRankingWeekly lacks equals/hashCode and has an
`@UniqueConstraint` that may be only documentation: either implement
identifier-based equals/hashCode (e.g., using id when non-null, otherwise
fallback to business key productId+scoreDate) on ProductRankingWeekly to make
behavior explicit for future Set/Map use, or add a clear class-level comment
stating equals/hashCode are intentionally omitted; additionally, verify that the
`@UniqueConstraint` on the entity is documented as non-enforcing when ddl-auto is
disabled and confirm the actual unique constraint exists in your external
migration scripts and is consistent with other modules' entity definitions (API
module) so schema/behavior remain synchronized.

Comment on lines +16 to +27
@Query("SELECT new com.loopers.domain.metrics.ProductScoreProjection("
+ "m.productId, "
+ "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) "
+ "FROM ProductMetrics m "
+ "WHERE m.metricDate BETWEEN :start AND :end "
+ "GROUP BY m.productId "
+ "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC")
List<ProductScoreProjection> findTopScores(
@Param("start") LocalDate start,
@Param("end") LocalDate end,
Pageable pageable
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Hibernate 6.4 JPQL ORDER BY alias referencing aggregate in SELECT with constructor expression

💡 Result:

In Hibernate 6.4 JPQL, you cannot use an ORDER BY alias that references an aggregate expression within a constructor expression (e.g., SELECT new DTO(field, count()) AS cnt ... ORDER BY cnt). This is not supported by the JPQL specification grammar. JPQL constructor expressions (NEW constructor_item ...) do not allow AS aliases on individual constructor_items inside the parentheses, as per JPA spec BNF: constructor_item ::= single_valued_path_expression | scalar_expression | aggregate_expression | identification_variable (no [AS] result_variable allowed). For tuple projections (SELECT field AS f1, count() AS cnt ...), aliases define Tuple element names accessible in Java via tuple.get("cnt"), but these aliases cannot be referenced in ORDER BY. ORDER BY must repeat the expression (e.g., ORDER BY count(*)) or use entity paths. This limitation persists in Hibernate 6.4; official docs show aggregate examples with AS aliases in SELECT but ORDER BY uses repeated expressions or paths. Older Hibernate docs (3.x) showed AS aliases usable in ORDER BY, but modern JPQL/HQL follows JPA spec strictly. Workarounds: 1. Repeat aggregate in ORDER BY: SELECT new DTO(e, count(o)) FROM Entity e JOIN e.orders o GROUP BY e ORDER BY count(o) DESC 2. Use CriteriaQuery for complex sorting. 3. Native SQL query for full SQL alias support. 4. Tuple projection without constructor, sort in Java (inefficient for large results).

Citations:


가중 합산식이 SELECT·ORDER BY에 중복 정의되어 향후 튜닝 시 불일치 위험이 있다.

운영 관점에서 가중치(0.1/0.2/0.7)를 조정할 때 두 위치 중 한쪽만 수정하면 정렬과 점수가 어긋나 랭킹 품질이 저상된다.

Hibernate 6.4 JPQL 사양에서는 생성자 표현식(NEW constructor_item) 내 개별 항목에 AS 별칭을 붙일 수 없으므로, ORDER BY에서 별칭으로 재사용하는 방식은 동작하지 않는다. 따라서 SELECT와 ORDER BY 양쪽에 동일한 집계식을 반복하거나, CriteriaQuery나 네이티브 SQL을 사용해야 한다.

♻️ 제안 리팩터(집계식 반복)
    `@Query`("SELECT new com.loopers.domain.metrics.ProductScoreProjection("
            + "m.productId, "
            + "CAST(SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) AS double)) "
            + "FROM ProductMetrics m "
            + "WHERE m.metricDate BETWEEN :start AND :end "
            + "GROUP BY m.productId "
            + "ORDER BY SUM(m.viewCount * 0.1 + m.likeCount * 0.2 + m.orderCount * 0.7) DESC")

또는 CriteriaQuery를 사용하여 중복을 근본적으로 제거하는 것을 고려한다.

가중치 변경 시 정렬 역전/동률 처리 회귀를 막기 위한 테스트 1건(가중치 경계값, 동점 상품의 productId tie-break 확인)을 추가하기를 권고한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/persistence/ProductMetricsJpaRepository.java`
around lines 16 - 27, The weighted-sum expression is duplicated in the JPQL
SELECT and ORDER BY inside ProductMetricsJpaRepository.findTopScores, risking
inconsistency when tuning weights; fix by centralizing the aggregation: either
repeat the exact same SUM(...) expression in both the constructor expression and
ORDER BY (ensure the numeric literals 0.1, 0.2, 0.7 remain identical), or
replace the JPQL query with a CriteriaQuery or native SQL that computes the
weighted score once and maps it to
com.loopers.domain.metrics.ProductScoreProjection; update findTopScores to use
the chosen approach and add a unit test that verifies ordering/tie-break
behavior when weights change.

Comment on lines +14 to +26
@Modifying
@Query(
value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) "
+ "VALUES (:productId, :scoreDate, :score) "
+ "ON DUPLICATE KEY UPDATE "
+ "score = :score",
nativeQuery = true
)
void upsert(
@Param("productId") Long productId,
@Param("scoreDate") LocalDate scoreDate,
@Param("score") Double score
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

@Modifying 네이티브 upsert에 clearAutomatically/flushAutomatically가 없다.

배치 Tasklet이 트랜잭션 내부에서 saveAllupsert를 호출하는데, 동일 트랜잭션 내에 같은 엔티티가 관리 상태로 존재하거나 이후 동일 키에 대한 조회가 발생하면, 네이티브 INSERT ... ON DUPLICATE KEY UPDATE는 DB만 갱신하고 1차 캐시는 반영되지 않아 stale read/덮어쓰기 꼬임이 발생할 수 있다. 배치 rerun 및 멱등성 관점에서 위험하므로 다음을 권장한다.

     `@Modifying`
+    `@Modifying`(clearAutomatically = true, flushAutomatically = true)
     `@Query`(
             value = "INSERT INTO mv_product_rank_weekly (product_id, score_date, score) "
                     + "VALUES (:productId, :scoreDate, :score) "
                     + "ON DUPLICATE KEY UPDATE "
                     + "score = :score",
             nativeQuery = true
     )

또한 루프를 통한 per-row upsert는 상위 100건 기준으로는 무리 없으나 왕복이 100회 발생하므로, 추후 chunk upsert(다중 VALUES) 또는 JDBC batch로 전환하는 것을 로드맵에 둘 것을 권장한다. 추가 테스트로 "같은 트랜잭션 내에서 연속 upsert → 즉시 조회 시 최신 score 반환" 시나리오를 추가해야 한다.

동일한 지적은 ProductRankingMonthlyJpaRepository에도 해당된다.

As per coding guidelines (**/*Repository*.java): "트랜잭션 밖 Lazy 로딩 문제와 영속성 컨텍스트 오염 가능성을 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/infrastructure/ranking/persistence/ProductRankingWeeklyJpaRepository.java`
around lines 14 - 26, The native upsert method
ProductRankingWeeklyJpaRepository.upsert is annotated with `@Modifying` but lacks
flushAutomatically/clearAutomatically, which can leave the persistence context
stale after the native INSERT ... ON DUPLICATE KEY UPDATE; update the `@Modifying`
on ProductRankingWeeklyJpaRepository.upsert (and mirror in
ProductRankingMonthlyJpaRepository.upsert) to include flushAutomatically = true
and clearAutomatically = true so the EntityManager is flushed and the
persistence context cleared after the native call, and add a unit/integration
test that performs saveAll → upsert → immediate read within the same transaction
to assert the returned score is the updated value; also add a TODO note to
replace per-row upsert with multi-row VALUES or JDBC batch in future to avoid N
round-trips.

Comment on lines +49 to +60
@DisplayName("date 파라미터가 null이면, 예외가 발생한다.")
@Test
void throwsException_whenDateIsNull() {
// arrange
ReflectionTestUtils.setField(tasklet, "date", null);

// act & assert
assertThatThrownBy(() -> tasklet.execute(
mock(StepContribution.class), mock(ChunkContext.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("date");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# MonthlyRankingTasklet의 date 파라미터 검증 로직 확인
fd -t f 'MonthlyRankingTasklet.java' | xargs -I{} sed -n '1,120p' {}
fd -t f 'WeeklyRankingTasklet.java' | xargs -I{} sed -n '1,120p' {}

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 6087


🏁 Script executed:

fd -t f 'MonthlyRankingTaskletTest.java' | xargs cat -n

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5992


date 파라미터의 빈 문자열, 공백, 형식 오류에 대한 경계 테스트가 누락되었다.

현재 테스트는 null 케이스만 검증한다. 운영 환경에서 CLI 파라미터로 "", 공백, 또는 잘못된 형식(예: "2026-04-14", "20260230")이 전달되면 LocalDate.parse()에서 DateTimeParseException이 발생하며, 이는 별도로 처리되지 않아 원인 파악이 어려운 장애로 이어질 수 있다. Tasklet의 입력 검증 계약을 명확히 테스트로 고정하는 것이 필요하다.

♻️ 추가 테스트 예시
+        `@DisplayName`("date 파라미터 형식이 yyyyMMdd가 아니면, 예외가 발생한다.")
+        `@Test`
+        void throwsException_whenDateFormatIsInvalid() {
+            ReflectionTestUtils.setField(tasklet, "date", "2026-04-14");
+
+            assertThatThrownBy(() -> tasklet.execute(
+                    mock(StepContribution.class), mock(ChunkContext.class)))
+                    .isInstanceOf(DateTimeParseException.class);
+        }
📝 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
@DisplayName("date 파라미터가 null이면, 예외가 발생한다.")
@Test
void throwsException_whenDateIsNull() {
// arrange
ReflectionTestUtils.setField(tasklet, "date", null);
// act & assert
assertThatThrownBy(() -> tasklet.execute(
mock(StepContribution.class), mock(ChunkContext.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("date");
}
`@DisplayName`("date 파라미터가 null이면, 예외가 발생한다.")
`@Test`
void throwsException_whenDateIsNull() {
// arrange
ReflectionTestUtils.setField(tasklet, "date", null);
// act & assert
assertThatThrownBy(() -> tasklet.execute(
mock(StepContribution.class), mock(ChunkContext.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("date");
}
`@DisplayName`("date 파라미터 형식이 yyyyMMdd가 아니면, 예외가 발생한다.")
`@Test`
void throwsException_whenDateFormatIsInvalid() {
ReflectionTestUtils.setField(tasklet, "date", "2026-04-14");
assertThatThrownBy(() -> tasklet.execute(
mock(StepContribution.class), mock(ChunkContext.class)))
.isInstanceOf(DateTimeParseException.class);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/test/java/com/loopers/job/ranking/MonthlyRankingTaskletTest.java`
around lines 49 - 60, Add boundary tests to MonthlyRankingTaskletTest to cover
empty, whitespace, and malformed date strings: create new `@Test` methods that use
ReflectionTestUtils.setField(tasklet, "date", "") and setField(..., "  ") and
setField(..., "2026-04-14") / "20260230", then call
tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); assert
that empty and whitespace date inputs throw IllegalArgumentException
(hasMessageContaining "date") and that malformed format inputs propagate
DateTimeParseException from LocalDate.parse; reference
MonthlyRankingTaskletTest, tasklet.execute, ReflectionTestUtils.setField, and
LocalDate.parse when adding these assertions.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

✅ Actions performed

Reviews paused.

- UseCase의 DateTimeParseException을 CoreException으로 래핑하여 400 응답 반환
- Tasklet의 DateTimeParseException을 IllegalArgumentException으로 래핑
- 인프라 레이어 @transactional(readOnly=true) 제거 (프로젝트 규칙 준수)
- @Modifying에 clearAutomatically/flushAutomatically 추가
- @Index 선언을 batch 모듈(테이블 소유자)로 이동, api 모듈에서 제거
- E2E 테스트 flaky 방지를 위해 run.id 파라미터 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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