Skip to content

[Volume 10] Spring Batch시스템 구현#408

Open
dd-jiny wants to merge 6 commits intoLoopers-dev-lab:dd-jinyfrom
dd-jiny:volume-10
Open

[Volume 10] Spring Batch시스템 구현#408
dd-jiny wants to merge 6 commits intoLoopers-dev-lab:dd-jinyfrom
dd-jiny:volume-10

Conversation

@dd-jiny
Copy link
Copy Markdown

@dd-jiny dd-jiny commented Apr 16, 2026

리뷰포인트는 PR 최하단에 작성해두었습니다.

📌 Summary

Spring Batch + Materialized View 패턴으로 주간/월간 랭킹 집계 시스템을 구축하고, 기존 랭킹 API에 period 파라미터를 추가하여 DAILY(Redis ZSET) / WEEKLY / MONTHLY(RDB MV) 3종 기간 랭킹을 단일 엔드포인트로 제공한다.

  • 배경: 일간 랭킹(Redis ZSET, 2일 TTL)만 존재 → 주/월간 기간 랭킹 부재. DB 집계는 실시간 API에 부적합.
  • 목표: ① 주간 롤링 7일 + 월간 자연월 집계 Job 구축, ② product_metrics_daily 일별 스냅샷 테이블 도입, ③ API에 period 분기 추가.
  • 결과: Weekly/Monthly Job E2E 3건 PASS, RankingScoreCalculator 5건 PASS, 기존 RankingV1ControllerE2ETest 9건 회귀 없음. 시니어 리뷰 P0 2건

🧭 Context & Decision

문제 정의

문제 1 — 주간/월간 랭킹 집계 파이프라인이 없다

Session 9에서 Redis ZSET 기반 일간 랭킹을 구축했으나, ZSET TTL 2일로 과거 데이터가 소실되어 주간/월간 기간 랭킹을 구성할 방법이 없다. 또한 DB 기반 GROUP BY + ORDER BY를 매 조회마다 실행하면 상품 누적 시 응답 지연이 발생한다.

리스크 설명
기간 데이터 소실 Redis ZSET TTL=2일 → 7일 전 데이터 참조 불가
DB 집계 부하 매 요청마다 SUM + ORDER BY → 10만 상품 이상 시 p95 악화
롱테일 재발 월간 랭킹에서도 "오래된 인기 상품" 고착 가능
성공 기준 목표값
주간/월간 랭킹 소스 일별 스냅샷 테이블 기반 집계
Batch 실행 주기 매일 D-1 누적 갱신 (K8s CronJob 03:10/03:20 KST)
멱등성 같은 baseDate로 재실행 시 결과 동일
윈도우 주간 롤링 7일 / 월간 자연월

문제 2 — DAILY와 WEEKLY/MONTHLY 랭킹이 서로 다른 기준으로 계산된다

Streamer는 주문 1건당 0.7 + 0.01·log₁₀(amount+1)를 Redis ZSET에 적재하지만, Batch는 order_count × 0.7만 사용. 같은 사용자가 period=DAILYperiod=WEEKLY 를 보면 순위가 불일치. 설계서 §4.1이 "값이 벗어나면 사용자 혼란" 경고를 명시했는데, 값은 같아도 식(formula)이 달랐다.

문제 3 — Facade @Cacheable self-invocation

getDailyRankingsgetRankings(LocalDate) 같은 빈 내부 호출로 Spring AOP proxy를 우회 → 내부 @Cacheable이 무효. 같은 데이터가 두 개의 캐시 키로 저장되어 메모리 2배 + evict 시 일관성 깨짐.


프로세스 흐름도 — 일간(Redis) + 주/월간(MV) 이중 랭킹 파이프라인

flowchart LR
    U["유저 행동<br>조회/좋아요/주문"]
    EH["EventHandler + Outbox"]

    subgraph K["Apache Kafka"]
        K1["catalog-events"]
        K2["order-events"]
    end

    subgraph STREAM["commerce-streamer"]
        CP["CatalogEventProcessor"]
        OP["OrderEventProcessor"]
    end

    subgraph DB["MySQL"]
        PM["product_metrics<br>(누적, Session 7)"]
        PMD["product_metrics_daily<br>(일별, Session 10 신규)"]
        MVW["mv_product_rank_weekly<br>(TOP 100)"]
        MVM["mv_product_rank_monthly<br>(TOP 100)"]
    end

    subgraph R["Redis ZSET"]
        Z1["ranking:all:yyyyMMdd<br>(Session 9)"]
    end

    subgraph BATCH["commerce-batch (신규)"]
        WJ["WeeklyRankingJob<br>03:10 KST"]
        MJ["MonthlyRankingJob<br>03:20 KST"]
    end

    subgraph API["commerce-api"]
        RF["RankingFacade<br>period 분기"]
        EP["GET /rankings?period="]
    end

    U --> EH
    EH --> K1
    EH --> K2
    K1 --> CP
    K2 --> OP

    CP -->|이중 upsert same TX| PM
    CP -->|이중 upsert same TX| PMD
    OP -->|이중 upsert same TX| PM
    OP -->|이중 upsert same TX| PMD

    CP -->|Pipeline ZINCRBY| Z1
    OP -->|ZINCRBY orderScore| Z1

    PMD -->|GROUP BY product_id<br>7일 윈도우| WJ
    PMD -->|GROUP BY product_id<br>자연월 윈도우| MJ
    WJ -->|TOP 100 원자 교체| MVW
    MJ -->|TOP 100 원자 교체| MVM

    Z1 -->|DAILY| RF
    MVW -->|WEEKLY| RF
    MVM -->|MONTHLY| RF
    RF --> EP

    classDef newS10 fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px,color:#000
    classDef legacy fill:#eeeeee,stroke:#616161,color:#000

    class PMD,MVW,MVM,WJ,MJ newS10
    class PM,Z1 legacy
Loading

범례: 빨간 박스는 Session 10 신규, 회색은 기존. 두 경로(Redis 실시간, MV 배치)는 같은 이벤트를 소비하지만 저장소 독립 → 한쪽 장애 시 다른 쪽 무영향.

관련 소스: CatalogEventProcessor · OrderEventProcessor · WeeklyRankingJobConfig · MonthlyRankingJobConfig · RankingFacade


핵심 결정 요약

# 결정 항목 최종 선택 핵심 근거
1 집계 소스 테이블 product_metrics_daily 신규 — Consumer가 누적 테이블과 같은 TX로 이중 upsert Redis ZSET TTL 2일 한계 회피 + 누적 테이블과 정합성 TX 보장
2 배치 Job 구조 3-Step: CleanupTmp → Chunk(Reader/Processor/Writer) → WriteTopRanks Step 분리로 재시도 단위화, Chunk 재시도 적용 가능 범위 격리
3 MV 갱신 전략 Step 2: DELETE + INSERT…SELECT 단일 TX (원자 교체) READ COMMITTED에서 외부 읽기가 "반쯤 비워진 MV"를 안 보도록 보장
4 주간 키 base_date (롤링 7일 윈도우 끝) 매일 갱신되는 "최근 7일" 의미가 직관적. ISO week는 월요일 시작 제약이 불편
5 월간 키 year_month VARCHAR(7) "yyyy-MM" (MySQL 예약어 백틱 필수) 자연월 단위 집계. 진행 중 월은 매일 덮어쓰기
6 TOP N 전략 전체 상품 집계 → tmp 테이블 → Tasklet LIMIT 100 score가 Processor 계산이라 Reader 단계 LIMIT 불가. tmp 분리로 Step 2 재실행 안전
7 가중치 공식 통일 (P0) Batch도 평균 금액 log 보너스 반영: order·(0.7 + 0.01·log₁₀(avgAmount+1)) streamer orderScore(amount)와 방향성 일치 → DAILY/WEEKLY/MONTHLY 순위 정합
8 Facade 단일 진입점 (P0) getRankings(LocalDate,…) 삭제, getDailyRankings 하나로 통합 self-invocation proxy 우회 문제 제거 + 캐시 키 중복 해소
9 MV 엔티티 위치 (DIP) Mv*Model/Id 4파일 infrastructure/ranking/domain/ranking/ 이동 프로젝트 규칙 "JPA 엔티티는 domain" 일관화
결정 1 — product_metrics_daily 이중 upsert: Consumer가 누적 + 일별을 같은 TX로 쓴다

문제 인식: 주간/월간 "기간별" 집계를 하려면 일별 델타가 필요하다. 기존 product_metrics누적 테이블이라 "7일 전부터 오늘까지"를 산출할 수 없다. 세 가지 방법을 검토했다.

방법 장점 단점
A. 일별 스냅샷 테이블 신규 (채택) 정확, 주간/월간 임의 윈도우 DB 테이블 1개 증가, Consumer 쓰기 2배
B. 누적 테이블을 그대로 score로 사용 구현 최소 "기간별" 의미 완전히 훼손 — 영원한 인기 상품
C. Redis ZSET 일간을 Batch가 읽어 합산 중간 테이블 불필요 TTL 2일이라 과거 5일 데이터 이미 소실

Consumer 쓰기 2배 부담에 대한 방어: 두 테이블을 같은 @Transactional 안에서 upsert한다. Catalog 이벤트는 Phase 2의 배치 합산 후 productId × metricDate 단위로 묶여 DB 호출 횟수가 이벤트 수보다 훨씬 적음. 하나 실패 시 둘 다 롤백.

// ProductMetricsService.java
@Transactional
public void incrementViewCountBy(Long productId, int count, LocalDate metricDate) {
    productMetricsRepository.incrementViewCountBy(productId, count);              // 누적
    productMetricsDailyRepository.incrementViewCountBy(productId, metricDate, count); // 일별
}

소스: ProductMetricsService · ProductMetricsDailyRepository · ProductMetricsDailyRepositoryImpl

Consumer 오프셋 커밋은 트랜잭션 성공 후에만 → at-least-once + EventHandledModel 멱등 (order 이벤트). Catalog 이벤트는 멱등 보장 없이 at-least-once 수용(Session 9 결정 유지).

90일 보관 청소 Job은 후속 과제로 분리.

결정 2 & 3 — Spring Batch 3-Step 구조와 원자 MV 교체
Step 0 — CleanupTmpTasklet:   TRUNCATE tmp_weekly_aggregate
Step 1 — AggregateStep (Chunk 1000):
         Reader(JdbcPagingItemReader, GROUP BY product_id)
         → Processor(WeeklyScoreProcessor, 가중 점수 계산)
         → Writer(JdbcBatchItemWriter, ON DUPLICATE KEY UPDATE)
         + 재시도: CannotAcquireLockException, TransientDataAccessResourceException (3회)
Step 2 — WriteTopRanksTasklet (단일 TX):
         DELETE FROM mv_product_rank_weekly WHERE base_date = ?
         INSERT...SELECT t.*, @rn:=@rn+1 FROM tmp ORDER BY score DESC LIMIT 100

소스: WeeklyRankingJobConfig · WeeklyCleanupTmpTasklet · WeeklyProductMetricsReader · WeeklyScoreProcessor · WeeklyTmpAggregateWriter · WriteWeeklyTopRanksTasklet

왜 3단계인가: Step 1이 실패해도 Step 2는 재실행 가능(tmp 유지). Step 0이 TRUNCATE를 먼저 하는 이유는 이전 실행 잔여물 배제.

왜 Step 2가 단일 TX인가: READ COMMITTED에서 DELETE 후 INSERT 중인 상태를 외부 API가 읽으면 "반쯤 비워진 MV"를 본다. 단일 TX로 감싸면 커밋 전까지 외부는 이전 상태를, 커밋 후는 새 상태만 본다.

MySQL 세션 변수로 rank_no 부여: (@rn := @rn + 1) 트릭으로 서브쿼리 내 순서 부여. ROW_NUMBER() 윈도우 함수가 더 깔끔하지만 이 구문이 호환성 면에서 안정적.

결정 7 — 가중치 공식 통일: Batch에 평균 금액 log 보너스 추가

문제 인식: 시니어 리뷰에서 DAILY와 WEEKLY/MONTHLY가 다른 기준으로 계산됨을 확인.

  • Streamer (DAILY): 주문 1건당 0.7 + 0.01·log₁₀(amount+1) — 개별 주문 금액을 ZSET에 실시간 누적
  • Batch (WEEKLY/MONTHLY): order_count × 0.7 — 집계 테이블은 개별 주문 금액을 이미 잃었음

정확 일치는 구조적으로 불가능. 집계 테이블에는 order_amount(기간 내 금액 합계)와 order_count만 있고, 개별 주문 금액 분포는 소실된다.

평균 금액 기반 근사 채택:

avgAmount = orderAmount / orderCount
orderScorePerItem = 0.7 + 0.01 × log₁₀(avgAmount + 1)
score = orderCount × orderScorePerItem + likeCount × 0.2 + viewCount × 0.1

구현: RankingScoreCalculator · 가중치 외부화: RankingWeights · yml: commerce-batch/application.yml

편향 분석: 젠센 부등식에 의해 log(avg) ≥ avg(log)이므로 근사는 실제보다 과대평가된다. 즉 batch score ≥ streamer score. 하지만:

  • "금액 큰 주문이 많을수록 점수 높다"는 방향성은 일치
  • DAILY와 WEEKLY/MONTHLY의 상대 순위는 유사하게 유지됨
  • 절댓값 일치가 아니라 사용자 체감 순위 일치가 목표

실제 테스트(E2E)에서 기존 Weekly: 2 > 1 > 3 순위가 새 공식 적용 후에도 동일하게 유지됨을 확인.

🏗️ Design Overview

변경 범위

모듈 신규 수정
commerce-batch batch/job/ranking/common/* (4파일) + weekly/* (6파일) + monthly/* (6파일), application.yml ranking.weights.*
commerce-streamer ProductMetricsDailyModel/Id/Repository/Impl/JpaRepository ProductMetricsService (이중 upsert 5개 메서드), CatalogEventProcessor·OrderEventProcessor (metricDate 전달)
commerce-api RankingPeriod, MvWeeklyRankModel/Id, MvMonthlyRankModel/Id, Mv{Weekly,Monthly}RankJpaRepository, MvRankingPage, PeriodRankingResponse RankingRepository(+ImplRankingService MV 메서드 확장, RankingFacade period switch + getDailyRankings 단일 진입점, RankingV1Controller period 파라미터, CacheConfig mvRankingCacheManager(10분)
docs/DDL session10-ranking-mv-ddl.sql 5개 테이블

주요 컴포넌트 책임

레이어 핵심 클래스 책임
Batch 공통 RankingWeights, RankingScoreCalculator, ProductAggregate(+WithScore) 가중치 외부화 + stateless 순수 함수 점수 계산
Batch Weekly WeeklyRankingJobConfig + 4 Step 클래스 7일 윈도우 집계 + tmp 경유 TOP 100 → MV 원자 교체
Batch Monthly MonthlyRankingJobConfig + 4 Step 클래스 자연월 윈도우, 구조 Weekly 복제 (year_month 키만 차이)
Streamer 메트릭 ProductMetricsService, ProductMetricsDailyJpaRepository 누적 + 일별 이중 upsert (같은 TX)
API domain RankingPeriod, MvWeekly·MonthlyRankModel, RankingRepository(+MvRankingEntry) 엔티티 + 조회 추상화 (Redis + MV 통합)
API application RankingFacade, MvRankingPage period switch, enrich(상품/브랜드 배치 조회), @Cacheable (rankings 10s / mvRankings 10m)
API interfaces RankingV1Controller, PeriodRankingResponse period 파라미터 파싱 + 응답 DTO

🔁 Flow Diagram

Weekly Batch Job 실행

sequenceDiagram
    autonumber
    participant CLI as CLI/K8s CronJob
    participant Job as weeklyRankingJob
    participant S0 as Step 0 Cleanup
    participant S1 as Step 1 Chunk
    participant S2 as Step 2 WriteTopRanks
    participant DB as MySQL

    CLI->>Job: baseDate=20260415
    Job->>S0: execute
    S0->>DB: TRUNCATE tmp_weekly_aggregate
    Job->>S1: execute (chunk size 1000)
    loop Reader → Processor → Writer
        S1->>DB: SELECT ... FROM product_metrics_daily<br>WHERE metric_date BETWEEN ? AND ?<br>GROUP BY product_id LIMIT 1000 OFFSET N
        DB-->>S1: ProductAggregate[1000]
        S1->>S1: calculate(like, order, view, amount)
        S1->>DB: INSERT INTO tmp ... ON DUPLICATE KEY UPDATE
    end
    Job->>S2: execute (단일 TX)
    S2->>DB: DELETE FROM mv_product_rank_weekly WHERE base_date=?
    S2->>DB: INSERT...SELECT t.*, @rn:=@rn+1 FROM tmp<br>ORDER BY score DESC LIMIT 100
    S2-->>Job: COMMIT
    Job-->>CLI: COMPLETED
Loading

시퀀스 구현: WeeklyRankingJobConfig — Step 0 WeeklyCleanupTmpTasklet, Step 1 WeeklyProductMetricsReaderWeeklyScoreProcessorWeeklyTmpAggregateWriter, Step 2 WriteWeeklyTopRanksTasklet

API Period 분기 조회

sequenceDiagram
    autonumber
    participant Client
    participant C as RankingV1Controller
    participant F as RankingFacade
    participant S as RankingService
    participant Z as Redis ZSET
    participant MV as MV Tables
    participant E as Product/Brand

    Client->>C: GET /rankings?period=WEEKLY&date=20260415
    C->>C: RankingPeriod.fromOrDefault("WEEKLY")
    C->>F: getRankings(WEEKLY, date, page, size)
    F->>F: switch(period)
    alt period = DAILY
        F->>S: getTopRankings(date, page, size+5)
        S->>Z: ZREVRANGE ranking:all:yyyyMMdd
    else period = WEEKLY
        F->>S: getWeeklyTop(baseDate, page, size)
        S->>MV: SELECT ... FROM mv_product_rank_weekly
    else period = MONTHLY
        F->>S: getMonthlyTop(yearMonth, page, size)
        S->>MV: SELECT ... FROM mv_product_rank_monthly
    end
    F->>E: findAllByIds (products + brands)
    F->>F: 필터(del_yn, display_status) + enrich
    F-->>C: MvRankingPage
    C-->>Client: PeriodRankingResponse
Loading

시퀀스 구현: RankingV1ControllerRankingPeriod.fromOrDefaultRankingFacadeRankingService (getTopRankings / getWeeklyTop / getMonthlyTop) → RankingRepositoryImpl

✅ Checklist

Step 0 — 설계

  • 01. batch-ranking-policy (A안: 롤링 7일 + 자연월 + 매일 D-1 확정)
  • 02. batch-ranking-design (Reader-level 집계, tmp 분리, 3-Step)
  • 03. senior-review-analysis
  • 04. batch-ranking-implementation (구현 가이드)

Step 1 — DDL + Streamer 이중 upsert

Step 2 — Batch Job 구현

Step 3 — API Period 분기

Step 4 — 테스트

Step 5 — 시니어 리뷰 P0 반영

📊 테스트 결과

테스트 결과 건수 범위
RankingScoreCalculatorTest ✅ PASS 5 가중합 + 평균 금액 log 보너스 + 소프트 위계
WeeklyRankingJobE2ETest ✅ PASS 2 집계 정확성 + 멱등 재실행
MonthlyRankingJobE2ETest ✅ PASS 1 year_month 키 + 월 경계 윈도우 필터
RankingFacadeTest ✅ PASS 2 self-invocation 제거 후 시그니처 이전
RankingV1ControllerE2ETest ✅ PASS 9 기존 DAILY API 회귀 없음

실동작 검증 로그 (Weekly Job)

Step 0 — weeklyCleanupTmpStep     519ms  TRUNCATE 완료
Step 1 — weeklyAggregateStep      453ms  Chunk Reader/Processor/Writer
Step 2 — writeWeeklyTopRanksStep  152ms  deleted=3, inserted=3 (재실행 멱등 확인)
총 소요 1s 773ms (시드 5건 기준)

변경 목적 및 핵심 기능

Spring Batch를 활용한 주간/월간 랭킹 집계 시스템 구현으로, 실시간 Redis ZSET 기반의 일일 랭킹을 Materialized View 패턴으로 확장. product_metrics_daily 테이블을 신규 생성하여 배치 작업이 기반할 일별 스냅샷을 제공하며, API는 통합된 RankingPeriod 파라미터(DAILY/WEEKLY/MONTHLY)로 단일 엔드포인트 지원.

핵심 변경점

배치 작업: WeeklyRankingJobMonthlyRankingJob은 3단계 구성(Cleanup TMP → Chunk 집계 → Top 100 MV 원자 교체)으로, product_metrics_daily에서 기간별 점수를 계산하고 mv_product_rank_weekly/mv_product_rank_monthly 테이블에 작성. 스코어 계산: 통일된 공식 orderCount × (0.7 + 0.01·log₁₀(avgAmount+1)) + likeCount×0.2 + viewCount×0.1 적용(RankingScoreCalculator — 배치는 평균 금액 기반 근사 사용으로 젠센 부등식상 과대평가 가능성 있음). 이중 upsert: 같은 트랜잭션 내에서 누적 메트릭과 일별 메트릭을 동시 업데이트하여 배치 집계 기반 확보(ProductMetricsService). 캐시: rankingCacheManager(10초, TOP 100용), mvRankingCacheManager(10분, MV용) 분리 구성 (CacheConfig). 재시도 정책: 배치는 청크 단위 3회 재시도(CannotAcquireLockException, TransientDataAccessResourceException).

리스크 및 주의사항

배치 근사 공식(avgAmount 기반)이 streamer의 실제 per-order 점수와 정확히 일치하지 않을 수 있어 DAILY 대비 WEEKLY/MONTHLY 상대 순위 편차 발생 가능. 또한 product_metrics_daily가 batch 모듈 엔티티로 선언되지 않아 수동 테이블 생성 필요한지 확인 필요(session10-ranking-mv-ddl.sql 참조). MV는 일 1회 갱신 기준으로 설계되었으므로 배치 스케줄 변경 시 캐시 TTL(10분) 조정 검토 필요.

테스트 및 검증

RankingScoreCalculator 단위 테스트 5개, WeeklyRankingJobE2ETest/MonthlyRankingJobE2ETest(Testcontainers 기반), RankingFacade 단위 테스트 2개, RankingV1ControllerE2ETest 9개 케이스로 파라미터 검증 및 빈 응답 처리 확인. K6 부하 테스트 스크립트 4종(load/accuracy/throughput/mixed-load)과 seed 스크립트 제공. 모든 표시된 테스트 PASS 상태.

🔍 리뷰 포인트

1. MV 갱신 직후 10분간 구 캐시 반환 — UX에 허용 가능한지 판단을 구하고 싶습니다

mvRankingCacheManager TTL 10분 고정, evict endpoint 미구현. 배치가 03:10 KST에 끝나면 03:20까지 이전 캐시 반환 가능성. 배치 완료 이벤트로 자동 evict을 구현할지, 10분 stale 허용으로 둘지 판단을 구합니다.

2. Reader가 전체 상품을 읽어 tmp 테이블에 저장하는 구조 — 상품 수 10만 이상일 때 확장 판단

현재 Reader(WeeklyProductMetricsReader / MonthlyProductMetricsReader)는 GROUP BY product_id기간 내 활동한 모든 상품을 읽고 Processor가 전부 score 계산. tmp 테이블이 전체 상품 수와 같아집니다. score가 Processor 계산이라 Reader에 LIMIT 100을 걸 수 없어 불가피한 구조지만, 10만 상품 규모에서 1~2분 완주 가능성을 판단을 구합니다.

dd-jiny and others added 6 commits April 8, 2026 01:18
  - RankingWeight 가중치 상수 (VIEW=0.01, LIKE=0.3, ORDER=1.0+ε×log)
  - RankingRepository/Impl: ZINCRBY+EXPIRE, SADD/SREM 좋아요 멱등
  - RankingService: 점수 증감 + 날짜 키 resolve + 좋아요 중복 방어
  - CatalogEventProcessor: 조회/좋아요 이벤트 → 랭킹 적재 (try-catch)
  - OrderEventProcessor: 주문 이벤트 → 건수+금액 보너스 적재 (try-catch)
  - ErrorType.INVALID_DATE_FORMAT 추가
  - 단위 테스트 10건 + 통합 테스트 6건 (Testcontainers Redis)
  Step 2: commerce-api 랭킹 API
  - RankingRepository(읽기 전용) / RankingRepositoryImpl (Replica 우선 읽기)
  - RankingService (페이지 계산, 0→1-based 순위 변환)
  - RankingFacade (버퍼 초과 요청 + 삭제/숨김 필터링 + 배치 조합)
  - GET /api/v1/rankings (date, page, size 파라미터)
  - 단위 테스트 4건 + E2E 테스트 2건

  Step 3: 상품 상세 순위 확장
  - ProductInfo / ProductV1Dto.ProductDetailResponse에 rank 필드 추가
  - ProductFacade에서 RankingService.getRank() try-catch (graceful degradation)
  - 기존 ProductFacadeTest 하위 호환 + 순위 테스트 3건 추가

  Step 4: Nice-to-Have
  - RankingCarryOverScheduler — 매일 23:50 Lua ZUNIONSTORE로 10% carry-over
  - incrementScoreBatch() — Redis Pipeline 배치 쓰기
  - 시간 단위 랭킹 (ranking:hourly:{yyyyMMddHH}, TTL 3h) + /hourly 엔드포인트
  - RankingWeightProperties — application.yml 가중치 외부화

  Step 5: k6 부하 테스트 (L1~L5 전체 PASS)
  - L1 E2E 정확성: checks 100%
  - L2 API 부하: p95=14ms, 에러율 0%, 82K req (50VU, 3분)
  - L3 이벤트 처리량: 읽기 p95=28ms (쓰기30+읽기10 VU)
  - L4 가중치 정확성: checks 99.6%
  - L5 운영 시뮬레이션: p95=19ms, 에러율 0%, 127K req (Spike 100VU)
  [가중치 변경 — round9.md "총합 1" 요구사항 충족]
  - RankingWeight: VIEW 0.01→0.1, LIKE 0.3→0.2, ORDER_BASE 1.0→0.7
  - 총합: 0.1 + 0.2 + 0.7 = 1.0
  - 주문 금액 보너스 안전 마진: 0.08 < 0.7 (8.75배)

  [죽은 코드 제거]
  - RankingWeightProperties 삭제 (어디서도 주입되지 않는 미사용 Bean)
  - application.yml ranking.weights 설정 블록 제거

  [DIP 개선 — RankingCarryOverScheduler]
  - RedisTemplate 직접 의존 → RankingRepository 도메인 인터페이스 경유로 변경
  - RankingRepository에 carryOver() 메서드 추가
  - RankingRepositoryImpl에 Lua 스크립트 이동, 상수 중복(KEY_PREFIX, TTL) 해소
  Kafka Consumer 이벤트 처리 병목 해소 (LAG 46,677 → 0, API p95 917ms → 63ms):

  1. Redis Pipeline 배치 처리
     - CatalogEventProcessor: 1건씩 → productId별 합산 후 Pipeline 일괄 처리
     - RankingRepositoryImpl.incrementScoreBatch(): executePipelined로 N건 1
  RTT

  2. Streamer @transactional 제거
     - CatalogEventProcessor에서 @transactional 제거하여 Redis 호출 중 DB
  커넥션 점유 방지
     - ProductMetricsService가 자체 트랜잭션 보유하므로 안전

  3. DB 조회수 배치 합산 upsert
     - incrementViewCountBy(productId, count): N건 이벤트를 productId당 1회
  upsert로 축소
     - INSERT ... ON DUPLICATE KEY UPDATE view_count = view_count + :count

  4. 랭킹 API Caffeine 로컬 캐시 (TTL 10초)
     - RankingFacade.getRankings()에 @Cacheable 적용
     - rankingCacheManager: maximumSize 100, expireAfterWrite 10s
feat: 주간/월간 랭킹 배치 + MV 기반 API 확장 — Spring Batch, 이중 upsert, period 파라미터

  ## commerce-batch
  - WeeklyRankingJob /
  MonthlyRankingJob (3-Step 구조)
    - Step 0: tmp 테이블 TRUNCATE
    - Step 1: product_metrics_daily
  GROUP BY → tmp upsert (Chunk, Cannot
  AcquireLock/TransientDataAccess
  재시도)
    - Step 2: tmp TOP 100 → MV 원자
  교체 (DELETE + INSERT…SELECT 단일
  TX)
  - RankingWeights
  (@ConfigurationProperties:
  ranking.weights.*)
  - RankingScoreCalculator —
  streamer의 orderScore와 방향성 일치
  (평균 금액 기반 log 근사)

  ## commerce-streamer
  - product_metrics_daily 이중 upsert
  파이프라인
    - ProductMetricsDailyModel +
  JpaRepository (IdClass 복합 PK)
    - CatalogEventProcessor: VIEWED
  배치 + LIKED/UNLIKED daily 반영
    - OrderEventProcessor:
  ORDER_CREATED에 metricDate 전달

  ## commerce-api
  - RankingPeriod enum (DAILY / WEEKLY
   / MONTHLY)
  - Mv{Weekly,Monthly}RankModel +
  @immutable + IdClass (year_month
  백틱 이스케이프)
  - RankingRepository 확장 — Redis
  ZSET + RDB MV 단일 인터페이스
  - RankingFacade.getRankings(period,
  ...) switch 분기 +
  mvRankingCacheManager(10분 TTL)
  - RankingV1Controller period
  파라미터 + PeriodRankingResponse
Session 10 — 주간/월간 랭킹을 Spring Batch + Materialized View로 구현하고
  API에 period 파라미터를 추가한다. 시니어 리뷰 P0 2건과 DIP 컨벤션 1건 동시
  반영.

  ## commerce-batch
  - WeeklyRankingJob / MonthlyRankingJob (3-Step 구조)
    - Step 0: tmp 테이블 TRUNCATE
    - Step 1: product_metrics_daily GROUP BY → tmp upsert (Chunk,
  CannotAcquireLock/TransientDataAccess 재시도)
    - Step 2: tmp TOP 100 → MV 원자 교체 (DELETE + INSERT…SELECT 단일 TX)
  - RankingWeights (@ConfigurationProperties: ranking.weights.*)
  - RankingScoreCalculator — streamer orderScore(amount)와 방향성 일치 (평균
  금액 기반 log 근사)

  ## commerce-streamer
  - product_metrics_daily 이중 upsert 파이프라인
  - CatalogEventProcessor VIEWED 배치 + LIKED/UNLIKED daily 반영
  - OrderEventProcessor ORDER_CREATED metricDate 전달

  ## commerce-api
  - RankingPeriod enum (DAILY / WEEKLY / MONTHLY)
  - Mv{Weekly,Monthly}RankModel + @immutable + IdClass (year_month 백틱
  이스케이프)
  - RankingRepository 확장 — Redis ZSET + RDB MV 단일 인터페이스
  - RankingFacade.getRankings(period, ...) switch 분기 +
  mvRankingCacheManager(10분 TTL)
  - RankingV1Controller period 파라미터 + PeriodRankingResponse
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 16, 2026

📝 Walkthrough

Walkthrough

Redis ZSET 기반 일/주/월간 상품 랭킹 시스템을 구현한다. 스트리머에서 실시간 이벤트를 처리하여 점수를 계산하고, 배치 작업으로 주/월간 집계 뷰를 생성한다. 상품 상세 조회에 랭킹 정보를 통합하며, 랭킹 조회 API를 추가한다.


Changes

Cohort / File(s) Summary
Domain Model & JPA Entity
apps/commerce-api/src/main/java/com/loopers/domain/ranking/*
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/*
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/*
랭킹 기간(DAILY/WEEKLY/MONTHLY) 열거형, 주/월간 JPA 엔티티 및 복합 키 클래스 4개 추가. 일일 지표 모델 및 저장소 인터페이스 추가.
Ranking Repository & Service (API)
apps/commerce-api/src/main/java/com/loopers/domain/ranking/{RankingRepository,RankingService}.java
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/{RankingRepositoryImpl,Mv*JpaRepository}.java
Redis(일일/시간별) 및 JPA 기반(주/월간) 랭킹 조회 메서드 정의. 구현체에서 Redis ZSET 접근 및 매터리얼라이즈드 뷰 쿼리 수행.
Ranking Facade & Application Service (API)
apps/commerce-api/src/main/java/com/loopers/application/ranking/{RankingFacade,RankingInfo,MvRankingPage}.java
일/주/월간 랭킹 페이지 응답 DTO 및 랭킹 아이템 DTO 추가. 랭킹 파사드에서 상품/브랜드 데이터 보강 및 캐싱 적용.
Ranking API Controller & DTO (API)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/{RankingV1Controller,RankingV1Dto}.java
/api/v1/rankings/api/v1/rankings/hourly 엔드포인트 추가. 요청 파라미터 검증(page, size 범위) 및 응답 DTO 추가.
Product Integration (API)
apps/commerce-api/src/main/java/com/loopers/application/product/{ProductFacade,ProductInfo}.java
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
ProductFacade에 RankingService 주입, 상품 상세 조회 시 랭킹 정보 추가. ProductInfo 및 ProductDetailResponse에 rank 필드 추가(nullable).
Event Handler (API)
apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewEventHandler.java
Map 생성 방식을 Map.of(...) 에서 new HashMap<>() 로 변경(기능 동일).
Error Handling (API)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
ConstraintViolationException 핸들러 추가, INVALID_DATE_FORMAT 에러 타입 추가.
Batch Job Configuration (Batch)
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/{WeeklyRankingJobConfig,step/*}.java
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/{MonthlyRankingJobConfig,step/*}.java
주/월간 랭킹 배치 작업 정의. 임시 테이블 정리, JDBC 페이징 집계, 점수 계산, 상위 100개 순위 기록 단계 포함.
Batch Common
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/{ProductAggregate*,RankingScoreCalculator,RankingWeights}.java
집계 데이터 모델 및 가중치 기반 점수 계산 로직. 좋아요(0.2), 주문(0.7), 조회(0.1) 기본 가중치.
Batch Configuration
apps/commerce-batch/src/main/resources/application.yml
ranking.weights 설정 추가.
Streamer Domain
apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/{RankingRepository,RankingService,RankingWeight}.java
apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/{ProductMetrics*Repository,ProductMetricsService}.java
Redis ZSET 쓰기 작업(점수 증가, 좋아요 추가/제거, 배치 업데이트) 정의. 일일 지표 저장소 추가 및 ProductMetricsService에 일일 지표 메서드 오버로드 추가.
Streamer Infrastructure
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/{ProductMetrics*}*.java`
Redis 파이프라인 기반 점수 업데이트, Lua 스크립트 기반 순환(carry-over) 로직 구현. JPA 기반 일일 지표 저장소 구현.
Streamer Consumer
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/{CatalogEventConsumer,CatalogEventProcessor,OrderEventProcessor}.java
apps/commerce-streamer/src/main/java/com/loopers/batch/RankingCarryOverScheduler.java
배치 이벤트 처리로 변경. PRODUCT_VIEWED 이벤트를 (productId, metricDate) 기준으로 집계하여 일괄 DB 및 Redis 업데이트. 좋아요/주문 이벤트 처리 시 RankingService 호출. 매일 23:50에 랭킹 순환 스케줄러 실행.
Test - API
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
apps/commerce-api/src/test/java/com/loopers/application/ranking/{RankingFacadeTest,RankingServiceTest}.java
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerE2ETest.java
ProductFacade 랭킹 통합 테스트(성공, 실패, 예외 케이스). RankingFacade 및 RankingService 단위 테스트. 랭킹 API E2E 테스트(빈 결과, 기본값, 파라미터 검증).
Test - Batch
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/{common/RankingScoreCalculatorTest,weekly/WeeklyRankingJobE2ETest,monthly/MonthlyRankingJobE2ETest}.java
점수 계산 로직 단위 테스트. 주/월간 배치 작업 E2E 테스트(데이터 정합성, 집계 순서, 멱등성).
Test - Streamer
apps/commerce-streamer/src/test/java/com/loopers/batch/RankingCarryOverSchedulerTest.java
apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/{RankingServiceTest,RankingWeightTest}.java
apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplIntegrationTest.java
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/{CatalogEventProcessorTest}.java
순환 스케줄러 Redis 동작 검증. RankingService 및 RankingWeight 로직 테스트. Redis 저장소 적분 테스트(점수 누적, 좋아요 멱등성). 이벤트 프로세서 배치 처리 및 메트릭 기록 테스트.
Load Tests
k6/scripts/session9/{ranking-api-load,ranking-e2e-accuracy,ranking-event-throughput,ranking-mixed-load,ranking-weight-accuracy}.js
k6/seed-session9.sh
지표 API 부하 테스트, E2E 정확성 검증, 이벤트 처리 및 랭킹 조회 혼합 부하, 가중치 검증 테스트. 테스트 사용자 100명 사전 생성 스크립트.
Streamer Dependencies
apps/commerce-streamer/build.gradle.kts
Redis Testcontainers 의존성 추가.

Sequence Diagram

sequenceDiagram
    participant User as Client
    participant Api as API<br/>(Commerce)
    participant Streamer as Streamer<br/>(Kafka→Redis)
    participant Redis as Redis<br/>(ZSET)
    participant Batch as Batch<br/>(Weekly/Monthly)
    participant DB as DB<br/>(JPA)

    User->>Api: 1. GET /api/v1/products/{id}<br/>(Detail with rank)
    Api->>Api: Fetch ProductFacade
    Api->>Redis: getRank(date, productId)
    Redis-->>Api: Rank (or null)
    Api-->>User: ProductDetail + rank

    User->>User: View Product / Like / Order
    User->>Streamer: Kafka: PRODUCT_VIEWED<br/>PRODUCT_LIKED<br/>ORDER_CREATED
    Streamer->>Streamer: Parse & Aggregate<br/>by (productId, metricDate)
    Streamer->>DB: Insert/Update<br/>product_metrics_daily
    Streamer->>Redis: ZINCRBY daily ZSET<br/>+ hourly ZSET
    Redis-->>Streamer: Updated score

    User->>Api: 2. GET /api/v1/rankings<br/>?date=today&page=0&size=20
    Api->>Api: RankingFacade.getDailyRankings
    Api->>Redis: ZRANGE (cached)
    Redis-->>Api: Top rankings
    Api->>DB: Hydrate Product/Brand data
    DB-->>Api: Enriched metadata
    Api-->>User: RankingPage<br/>(items + pagination)

    Batch->>DB: 3. Weekly/Monthly Job<br/>(trigger: nightly)
    Batch->>DB: Read product_metrics_daily<br/>(7-day / month window)
    Batch->>Batch: Aggregate & Calculate<br/>RankingScore
    Batch->>DB: Write to<br/>mv_product_rank_weekly<br/>mv_product_rank_monthly
    Batch->>Batch: Scheduled at 23:50<br/>CarryOver: Copy<br/>today→tomorrow<br/>with 10% weight
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120분

근거:

  • 범위: 3개 모듈(API, Batch, Streamer) + 40개 이상의 신규 파일
  • 복잡도: Redis ZSET 연산, 배치 집계 로직, 이벤트 배치 처리, 스케줄 작업 등 다층 구조
  • 운영 리스크:
    • Redis TTL/키 관리(일 2개, 시간 3개 설정)
    • 배치 작업 실패 시 데이터 정합성
    • 캐시 일관성(Caffeine 10초/10분 만료)
    • 순환 로직의 정확성(가중치 0.1 적용)
  • 테스트 이질성: 단위, 통합, E2E, 부하 테스트가 분산됨
  • 설정 변경: 캐시, 가중치, 배치 파라미터 다수

Possibly related PRs


운영 관점 주요 지적

1. Redis TTL 전략의 명확성 부족

문제점:

  • 일일 랭킹 TTL 2일, 시간별 TTL 3시간으로 하드코딩되어 있으나, 순환(carry-over) 로직에서 과거 데이터 참조 시점과의 정합성이 불명확하다.
  • 예: 23:50에 순환 실행 시 어제 데이터가 정확히 남아있는지 보장 안 됨.

수정안:

  • TTL 설정을 application.yml에서 외부화하고, 배치 작업 실행 시점보다 최소 1시간 이상 여유 있게 설정
  • 순환 전 소스 키(yesterday) 존재 여부를 로그하고, 없으면 alert 발생

추가 테스트:

  • TTL 경계값 테스트: 순환 시점에 2.5일 된 데이터 강제 생성 후 정상 복사되지 않음을 검증

2. 배치 작업 실패 및 재시도 정책 미흡

문제점:

  • Weekly/Monthly 배치에서 maxRetries=3, retryableExceptions=CannotAcquireLockException, TransientDataAccessResourceException만 설정
  • 네트워크 오류, 데이터베이스 연결 끊김 등으로 인한 부분 실패(예: 행 삽입 완료 후 INSERT SELECT 실패) 시 임시 테이블에 고아 데이터 남음

수정안:

  • 매달 마지막 실행 전에 tmp_weekly_aggregate, tmp_monthly_aggregate TRUNCATE를 명시적으로 수행하는 Cleanup 태스크릿 확인
  • 배치 작업 실패 시 자동 롤백 확인 및 로그 레벨을 ERROR로 설정

추가 테스트:

  • 데이터베이스 연결 끊김 시뮬레이션 후 재시도 동작 검증
  • 부분 실패 후 재실행 시 데이터 중복 없음을 검증

3. 이벤트 배치 처리의 부분 실패 처리

문제점:

  • CatalogEventProcessor.processBatch()에서 PRODUCT_VIEWED 집계는 성공했으나 Redis 파이프라인 실행 실패 시, DB 메트릭은 증가했으나 랭킹 점수는 미반영되어 불일치 발생 가능
  • 에러 로깅 후 계속 진행하므로, 실패한 배치의 어느 레코드가 미처리됐는지 추적 불가능

수정안:

  • 각 처리 단계(DB 메트릭, Redis 점수, 좋아요 처리)를 분리된 트랜잭션으로 관리
  • Redis 파이프라인 실패 시 실패 레코드의 offset 범위를 별도 로그로 기록하고, 모니터링 대시보드에 노출
  • 재처리 가능한 상태로 예외 발생 시 Kafka offset 롤백 고려

추가 테스트:

  • 배치 중간에 Redis 연결 끊김 시뮬레이션 → DB는 증가, Redis는 미반영 상태 확인
  • 부분 실패 후 같은 배치 재처리 시 중복 카운팅 없음을 검증

4. 캐시 일관성 문제

문제점:

  • RankingFacade에서 일일 랭킹은 Caffeine 캐시(10초 만료)로 관리되나, 시간별 랭킹(getHourlyRankings)은 캐싱 안 됨
  • 시간 경계(예: 14:59:50 → 15:00:10)에 여러 VU가 동시 요청 시 중복 계산 및 Redis 부하 증가

수정안:

  • 시간별 랭킹도 mvRankingCacheManager로 캐싱(TTL 3분 권장)하되, 시간 변경 감지 시 무효화 로직 추가
  • 캐시 key에 hour 정보 포함(예: rankings:hourly:2026-04-15T14)

추가 테스트:

  • 시간 경계 전후 10초 이내의 빠른 연속 요청에서 캐시 hit rate 검증

5. 가중치 기반 점수 계산의 정밀도

문제점:

  • RankingScoreCalculator에서 주문 점수를 ORDER_BASE + 0.01 * log10(avgAmount + 1)로 계산
  • 배치 작업에서는 이 로직을 재구현(RankingWeights)하므로, 스트리머(실시간)와 배치(주/월간)의 점수 계산 로직이 분리됨
  • 금액이 큰 주문의 경우 log 계산 오차 누적 가능성

수정안:

  • RankingScoreCalculator의 계산 공식을 배치 및 스트리머에서 동일하게 사용하는 공유 유틸로 이동
  • 점수 반올림 모드를 명시(HALF_UP 확인됨)하고, 배치와 실시간의 누적 점수 차이가 임계값 이상이면 alert 발생

추가 테스트:

  • 극한값 테스트: 주문 금액 1원 vs. 1억원에서 log 차이 검증
  • 배치 재실행 후 누적 점수 동일함을 검증

6. 파라미터 검증 불완전

문제점:

  • RankingV1Controller에서 page, size 파라미터 검증이 @Min, @Max 어노테이션으로만 되어 있으나, 정확한 상한선이 테스트 코드(MAX_SIZE=100)와 달라 혼동 가능
  • date 파라미터의 yyyyMMdd 파싱 실패 시 INVALID_DATE_FORMAT 에러를 반환하는데, 이는 API 문서에 명시되지 않음

수정안:

  • 파라미터 상한선을 constants로 정의하고, 컨트롤러 어노테이션과 테스트에서 동일하게 참조
  • OpenAPI(Swagger) 문서에 date 형식 및 가능한 에러 응답 명시

추가 테스트:

  • 경계값 테스트: page=-1, size=0, size=101, date=invalid 각각 확인
  • 400 에러 응답 메시지 일관성 검증

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

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

Note

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

Caution

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

⚠️ Outside diff range comments (1)
.claude/settings.local.json (1)

223-235: ⚠️ Potential issue | 🟠 Major

사용자 홈 경로가 고정된 hook 설정은 공유 설정으로 깨진다

/home/mdskkdj/.claude-hooks/notify.sh를 직접 박아 두면 다른 계정, CI, 컨테이너 환경에서 바로 실패한다. 운영 관점에서는 개발자마다 동작이 달라져 장애 재현과 지원이 어려워진다. 이 파일을 로컬 전용으로 버전 관리 대상에서 제외하거나, 저장소 내부 래퍼 스크립트 또는 환경변수로 경로를 주입하도록 바꾸는 편이 안전하다. 추가로 다른 $HOME을 가진 깨끗한 환경에서도 hook 부재가 기본 작업을 막지 않는지 확인하는 테스트가 필요하다.

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

In @.claude/settings.local.json around lines 223 - 235, 현재 설정에 하드코딩된 절대경로
"command": "/home/mdskkdj/.claude-hooks/notify.sh" 때문에 다른 계정/CI/컨테이너에서 실패하므로 절대
경로를 제거하고 환경에 따라 동작하도록 수정하세요: .claude/settings.local.json의 해당 "command" 값을 로컬
전용으로 버전관리에서 제외하거나(예: .gitignore에 settings.local.json), 저장소 내부의 래퍼 스크립트(예:
repo-level hooks/notify.sh)로 대체하거나 환경변수($HOME 또는 CLAUDE_HOOK_PATH)로 주입하도록 바꾸고,
해당 스크립트가 없을 때는 no-op 또는 안전한 폴백 동작을 하도록 보장해 테스트(깨끗한 환경에서 hook 부재 시 기본 작업이 차단되지
않는지)도 추가하세요.
🟠 Major comments (20)
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java-146-158 (1)

146-158: ⚠️ Potential issue | 🟠 Major

Bean Validation 예외 핸들러 추가는 현재 에러 처리 규약과 충돌한다.

운영 관점에서 검증 실패 경로가 CoreException 경로와 Bean Validation 경로로 분기되면, API별 메시지/코드 일관성이 깨져 장애 분석과 클라이언트 대응이 어려워진다. 수정안으로 Line 153-158 핸들러를 제거하고, 입력 검증 실패는 도메인/애플리케이션에서 CoreException으로 변환해 기존 handle(CoreException e) 단일 경로를 유지해야 한다. 추가 테스트로 잘못된 page/size/period 요청에 대해 항상 동일한 에러 코드/포맷이 반환되는 E2E 케이스를 보강해야 한다.

Based on learnings: "enforce unified error handling by routing errors through CoreException to ApiControllerAdvice ... Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling."

🤖 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/ApiControllerAdvice.java`
around lines 146 - 158, Remove the Bean Validation exception handler method
handleBadRequest(ConstraintViolationException e) from ApiControllerAdvice and
ensure all input validation failures are converted to and thrown as
CoreException from the domain/application layer so they flow through the
existing handle(CoreException e) path; update or add E2E tests to assert invalid
page/size/period requests return the same error code/format produced by
handle(CoreException e) to enforce a single error schema.
apps/commerce-streamer/build.gradle.kts-24-25 (1)

24-25: ⚠️ Potential issue | 🟠 Major

테스트 의존성 버전을 명시적으로 고정해야 한다.

com.redis:testcontainers-redis는 Spring Cloud BOM에 포함되지 않으므로 중앙 버전 관리 대상이 아니다. 현재 코드는 두 곳(apps/commerce-streamer:25, modules/redis:10)에서 버전 없이 선언되어 있어 의존성 해석이 불확정되고 빌드 재현성이 보장되지 않는다.

다음 중 하나의 방식으로 버전을 고정해야 한다.

  1. gradle.properties에 버전 변수 추가 후 참조
  2. 각 build.gradle.kts에서 명시 버전 지정
  3. 루트 build.gradle.kts의 resolutionStrategy를 확장하여 com.redis 그룹도 관리

현재 resolutionStrategy(라인 73-79)는 org.testcontainers만 1.21.4로 강제하고 있으므로 com.redis 그룹은 제어되지 않는다.

수정 방안

방안 A: gradle.properties에 버전 추가 (권장)

### Library versions ###
springDocOpenApiVersion=2.7.0
+testcontainersRedisVersion=2.2.2
springMockkVersion=4.0.2

apps/commerce-streamer/build.gradle.kts와 modules/redis/build.gradle.kts:

-    testImplementation("com.redis:testcontainers-redis")
+    testImplementation("com.redis:testcontainers-redis:${project.properties["testcontainersRedisVersion"]}")

방안 B: 루트 build.gradle.kts resolutionStrategy 확장

configurations.configureEach {
    resolutionStrategy.eachDependency {
        if (requested.group == "org.testcontainers") {
            useVersion("1.21.4")
        }
+       if (requested.group == "com.redis") {
+           useVersion("2.2.2")
+       }
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-streamer/build.gradle.kts` around lines 24 - 25, The test
dependency com.redis:testcontainers-redis is declared without a version in
apps/commerce-streamer (testImplementation("com.redis:testcontainers-redis")),
making builds non-reproducible because the Spring Cloud BOM doesn't manage it;
fix by pinning the version—preferred: add a property in gradle.properties (e.g.,
redisTestcontainersVersion) and reference it from apps/commerce-streamer and
modules/redis testImplementation declarations; alternatively set the explicit
version inline in those build.gradle.kts files, or extend the root
build.gradle.kts resolutionStrategy (the block currently forcing
org.testcontainers to 1.21.4 around lines 73-79) to also force
com.redis:testcontainers-redis to the chosen version so both modules resolve
consistently.
apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingPage.java-18-29 (1)

18-29: ⚠️ Potential issue | 🟠 Major

items를 그대로 보관하면 응답 객체가 외부 변경에 오염된다

List<RankingInfo> items를 방어적 복사 없이 저장하면 호출자가 원본 리스트를 수정하거나 page.items() 반환값을 수정할 때 record 객체의 상태가 함께 변경된다. 캐시된 응답이나 재사용된 객체가 요청 사이에서 오염되어 재현 어려운 버그로 이어질 수 있다. compact constructor에서 items = List.copyOf(items)로 고정하고 null은 즉시 실패시키는 편이 안전하다.

제안 수정
 public record MvRankingPage(
         RankingPeriod period,
         LocalDate baseDate,
         String yearMonth,
         Integer windowDays,
         LocalDateTime aggregatedAt,
         int page,
         int size,
         long totalElements,
         int totalPages,
         List<RankingInfo> items
 ) {
+    public MvRankingPage {
+        items = List.copyOf(items);
+    }
 
     public static MvRankingPage daily(LocalDate date, int page, int size,
                                       long totalElements, int totalPages,
                                       List<RankingInfo> items) {

추가로 원본 리스트 변경이 반영되지 않는지와 반환 리스트가 수정 불가인지 확인하는 테스트를 작성한다.

🤖 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/MvRankingPage.java`
around lines 18 - 29, The MvRankingPage record stores the List<RankingInfo>
items without defensive copying causing external mutation to corrupt the record;
add a compact constructor for MvRankingPage that validates items is non-null
(fail fast) and assigns items = List.copyOf(items) to make an unmodifiable
snapshot, and update any factory/usage sites to rely on the compact constructor;
also add tests that mutate the original list after construction and attempt to
modify the returned page.items() to assert the record's internal list does not
change and that returned list is unmodifiable.
apps/commerce-streamer/src/main/java/com/loopers/batch/RankingCarryOverScheduler.java-23-35 (1)

23-35: ⚠️ Potential issue | 🟠 Major

스케줄러 예외 발생 시 처리 로직이 없다.

rankingRepository.carryOver() 호출 중 Redis 연결 실패 등 예외가 발생하면 스케줄러가 조용히 실패한다. 운영 환경에서 장애 인지를 위해 예외 로깅 및 알림 메트릭 발행이 필요하다.

🛠️ 수정안
     `@Scheduled`(cron = "0 50 23 * * *")
     public void carryOver() {
         LocalDate today = LocalDate.now();
         LocalDate tomorrow = today.plusDays(1);

-        boolean success = rankingRepository.carryOver(today, tomorrow, CARRY_OVER_WEIGHT);
-
-        if (success) {
-            log.info("[RankingCarryOver] 성공: {} → {} (weight={})", today, tomorrow, CARRY_OVER_WEIGHT);
-        } else {
-            log.info("[RankingCarryOver] 스킵: 오늘 키 없음 ({})", today);
+        try {
+            boolean success = rankingRepository.carryOver(today, tomorrow, CARRY_OVER_WEIGHT);
+
+            if (success) {
+                log.info("[RankingCarryOver] 성공: {} → {} (weight={})", today, tomorrow, CARRY_OVER_WEIGHT);
+            } else {
+                log.info("[RankingCarryOver] 스킵: 오늘 키 없음 ({})", today);
+            }
+        } catch (Exception e) {
+            log.error("[RankingCarryOver] 실패: {} → {}", today, tomorrow, e);
         }
     }
🤖 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/batch/RankingCarryOverScheduler.java`
around lines 23 - 35, The carryOver method in RankingCarryOverScheduler lacks
exception handling; wrap the rankingRepository.carryOver(today, tomorrow,
CARRY_OVER_WEIGHT) call inside a try-catch that catches Exception, logs the full
error (with stack/exception message) via the class logger (log.error) including
today/tomorrow/CARRY_OVER_WEIGHT context, and on failure emit or increment an
operational metric/alert (e.g., a counter or monitoring client) so failures are
visible; ensure the existing success/skip logging remains inside the try and
that the catch logs and increments the metric/alert before returning.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java-34-40 (1)

34-40: ⚠️ Potential issue | 🟠 Major

processBatch 예외 시 무한 재처리 위험

processBatch()에서 예외가 발생하면 ack.acknowledge()가 호출되지 않아 동일 배치가 무한 반복 처리될 수 있다. 특정 레코드의 파싱 오류나 DB 연결 실패가 지속되면 컨슈머가 해당 파티션에서 진행하지 못하고 lag이 누적된다.

운영 관점에서 장애 전파와 메시지 처리 지연으로 이어진다.

🔧 에러 핸들링 추가 제안
     public void consume(List<ConsumerRecord<Object, Object>> records, Acknowledgment ack) {
         log.debug("[CatalogConsumer] 수신 {}건", records.size());
 
-        catalogEventProcessor.processBatch(records);
+        try {
+            catalogEventProcessor.processBatch(records);
+        } catch (Exception e) {
+            log.error("[CatalogConsumer] 배치 처리 실패, {}건 스킵. offset 범위: {}-{}",
+                    records.size(),
+                    records.isEmpty() ? "N/A" : records.get(0).offset(),
+                    records.isEmpty() ? "N/A" : records.get(records.size() - 1).offset(),
+                    e);
+            // 근사 랭킹 시스템이므로 스킵 후 진행 (at-least-once 허용)
+        }
 
         ack.acknowledge();
     }

추가 테스트: 배치 내 일부 레코드가 파싱 실패해도 나머지가 처리되는지, 전체 실패 시 로깅 후 진행하는지 검증하는 테스트가 필요하다.

🤖 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/interfaces/consumer/CatalogEventConsumer.java`
around lines 34 - 40, In consume(List<ConsumerRecord<Object, Object>> records,
Acknowledgment ack) wrap the call to catalogEventProcessor.processBatch(records)
in a try/catch and ensure ack.acknowledge() is always invoked (use finally) so a
thrown exception does not block offset commits; on exception log the full error
(include the exception) and route the failed batch or offending records to a
dead-letter handler (e.g., sendToDlq or catalogEventProcessor.handleFailedBatch)
or mark them as skipped, and add/recommend per-record error handling inside
catalogEventProcessor.processBatch to avoid whole-batch failures from
single-record parse/DB errors.
apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvWeeklyRankJpaRepository.java-17-22 (1)

17-22: ⚠️ Potential issue | 🟠 Major

findByBaseDateOrderByRankNoAsc() 메서드의 정렬 성능을 보장하는 인덱스 추가가 필수다.

현재 PK는 (base_date, product_id)만 정의되어 있으나, findByBaseDateOrderByRankNoAsc() 메서드는 base_date 조건과 rank_no 정렬을 조합한다. PK로는 rank_no 정렬을 커버할 수 없어, 운영 데이터가 쌓일수록 페이징된 조회마다 추가 정렬 비용이 누적된다. 또한 findAggregatedAt() 쿼리의 MAX(aggregatedAt) 집계도 최적화 기회가 있다.

수정안:

  • 엔티티에 @Index 어노테이션 추가: @Index(name = "idx_base_date_rank_no", columnList = "base_date,rank_no")
  • aggregatedAt 집계 최적화를 위해 @Index(name = "idx_base_date_agg_at", columnList = "base_date,aggregated_at") 추가
  • 운영 환경 DDL/마이그레이션 스크립트에도 동일한 인덱스 반영 확인

추가 테스트로 EXPLAIN ANALYZE를 통해 두 쿼리 모두 인덱스 활용을 검증하고, 주간 갱신 배치 실행 후 대량 조회 시나리오에서 응답 시간을 측정하도록 통합 테스트에 포함한다.

🤖 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/MvWeeklyRankJpaRepository.java`
around lines 17 - 22, Add DB indexes to the MvWeeklyRankModel entity to support
findByBaseDateOrderByRankNoAsc and findAggregatedAt: add an
`@Index`(name="idx_base_date_rank_no", columnList="base_date,rank_no") to cover
the baseDate + rankNo access pattern used by
findByBaseDateOrderByRankNoAsc(LocalDate, Pageable) and add an
`@Index`(name="idx_base_date_agg_at", columnList="base_date,aggregated_at") to
support the MAX(aggregatedAt) aggregation used by findAggregatedAt(LocalDate);
update your migration/DDL scripts to create these same indexes in production and
run EXPLAIN ANALYZE in tests for both queries to verify index usage and measure
paging performance after the weekly batch update.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyTmpAggregateWriter.java-29-35 (1)

29-35: ⚠️ Potential issue | 🟠 Major

upsert 동안 재처리 시 배치 실패 가능성이 있다

ON DUPLICATE KEY UPDATE 사용 시 동일 값을 재삽입하면 MySQL에서 affected_rows=0을 반환한다. 기본 설정인 assertUpdates=true 상태에서는 0을 업데이트 실패로 판단하여 배치 재기동 또는 재시도 시나리오에서 예외가 발생한다. WeeklyTmpAggregateWriter의 주석에서 "retry/재시도 시 안전장치로 ON DUPLICATE KEY UPDATE 사용"이라 명시한 것과 배치된다.

.assertUpdates(false)를 명시하여 idempotent 재처리를 허용해야 한다. 추가로 동일 청크를 연속 두 번 처리하는 시나리오로 E2E 테스트를 추가하여 재기동 시 안정성을 검증한다.

수정 예시
     public static JdbcBatchItemWriter<ProductAggregateWithScore> create(DataSource dataSource) {
         return new JdbcBatchItemWriterBuilder<ProductAggregateWithScore>()
                 .dataSource(dataSource)
                 .sql(SQL)
                 .itemSqlParameterSourceProvider(MonthlyTmpAggregateWriter::toParams)
+                .assertUpdates(false)
                 .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/monthly/step/MonthlyTmpAggregateWriter.java`
around lines 29 - 35, The JdbcBatchItemWriter returned by
MonthlyTmpAggregateWriter.create uses a builder that currently enforces update
assertions, which causes failures when ON DUPLICATE KEY UPDATE results in 0
affected rows during idempotent retries; update the JdbcBatchItemWriterBuilder
in MonthlyTmpAggregateWriter.create to call .assertUpdates(false) so
reprocessing the same values doesn't throw, and add an E2E test that runs the
same chunk twice (or restarts the job mid-chunk) to verify idempotent retry
behavior for the SQL using ON DUPLICATE KEY UPDATE.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventProcessor.java-132-138 (1)

132-138: ⚠️ Potential issue | 🟠 Major

occurredAt 파싱 실패를 현재 시각으로 대체하면 기간 집계가 영구 오염된다.

occurredAt가 누락되거나 형식이 달라지면 현재 시각으로 처리되어 metric_date와 일간 랭킹 키가 소비 시점 기준으로 기록된다. 운영에서 컨슈머 지연이나 재처리가 발생하면 주문이 잘못된 일자/주차/월간 집계로 들어가고, product_metrics_daily를 원천으로 쓰는 배치 결과까지 틀어져 자동 복구되지 않는다. 파싱 실패 시에는 예외로 처리해 재시도하게 하거나, 정말 폴백이 필요하면 ConsumerRecord.timestamp()Asia/Seoul로 명시 변환한 값만 사용하라. 추가로 occurredAt 누락/파손 이벤트에서 현재 시각이 아니라 명시된 폴백 또는 실패 경로가 선택되는 테스트를 넣는 것이 필요하다.

🤖 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/interfaces/consumer/OrderEventProcessor.java`
around lines 132 - 138, The parseOccurredAt method currently swallows parse
errors and returns LocalDateTime.now(), which corrupts metric_date; change
parseOccurredAt to throw an exception on missing/invalid occurredAt (so the
record processing can be retried) or, if a fallback is explicitly required,
return the Kafka record timestamp converted to Asia/Seoul using
ConsumerRecord.timestamp() instead of LocalDateTime.now(); update the method
parseOccurredAt and any callers to propagate/handle the exception path, and add
unit tests that cover missing/invalid occurredAt to assert either the
exception/retry behavior or the explicit Kafka-timestamp-in-Asia/Seoul fallback.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyTmpAggregateWriter.java-22-33 (1)

22-33: ⚠️ Potential issue | 🟠 Major

임시 집계 테이블이 실행 스코프로 분리되지 않아 결과가 섞일 수 있다.

현재 writer가 tmp_weekly_aggregate에 실행 식별자 없이 데이터를 upsert하므로, 서로 다른 baseDate 실행이 겹치면 한 실행의 chunk 결과가 다른 실행의 중간 결과를 덮어쓸 수 있다. 이후 MV 교체 단계가 전역 tmp 테이블을 읽기 때문에 잘못된 주간 랭킹이 저장될 수 있다. tmp_weekly_aggregatebase_date 또는 job_execution_id를 포함해 PK/UK와 조회 조건을 함께 묶거나, 실행별 임시 테이블을 사용하라. 추가로 서로 다른 baseDate 두 건을 겹쳐 실행해도 각 MV 결과가 분리되는 E2E 테스트를 추가하는 것이 필요하다.

🤖 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/weekly/step/WeeklyTmpAggregateWriter.java`
around lines 22 - 33, The tmp table upsert in WeeklyTmpAggregateWriter uses a
global tmp_weekly_aggregate without an execution scope, so concurrent runs can
clobber each other; modify the INSERT/ON DUPLICATE KEY logic and schema usage to
include an execution discriminator (e.g., add base_date or job_execution_id
columns to tmp_weekly_aggregate and its PK/UK) and ensure all queries in
WeeklyTmpAggregateWriter (the SQL constant) and downstream MV swap/read logic
filter/join by that execution column, or alternatively create per-execution temp
tables; also add an E2E test that runs two different baseDate jobs concurrently
and asserts each MV result remains isolated.
k6/scripts/session9/ranking-api-load.js-25-26 (1)

25-26: ⚠️ Potential issue | 🟠 Major

toISOString()의 UTC 기준 계산으로 KST 요청 날짜가 하루 밀릴 수 있다.

toISOString()은 항상 UTC 기준으로 날짜를 반환하므로, KST 자정 전후에는 실제 운영 날짜와 다른 값을 date 파라미터로 전송할 수 있다. 예를 들어 KST 오전 8시(UTC 자정 이전)에 요청하면 전날 날짜로 요청되어, 운영 환경에서는 빈 랭킹이나 잘못된 캐시 키를 대상으로 부하 테스트를 실행하게 되어 성능 지표가 왜곡된다.

k6의 Goja 엔진은 Intl.DateTimeFormat 등 타임존 기능을 지원하지 않으므로, UTC 시간에 9시간을 더하여 KST로 변환하는 방식으로 처리해야 한다. 예를 들어:

function seoulDateString(date = new Date()) {
  const kstTime = date.getTime() + (9 * 60 * 60 * 1000);
  const kstDate = new Date(kstTime);
  const year = kstDate.getUTCFullYear();
  const month = String(kstDate.getUTCMonth() + 1).padStart(2, '0');
  const day = String(kstDate.getUTCDate()).padStart(2, '0');
  return `${year}${month}${day}`;
}

TODAY를 이 함수로 계산하거나, BASE_DATE 환경변수로 주입하는 방식으로 수정하고, 추가로 UTC 00:0009:00 구간(KST 09:0018:00)과 UTC 15:0024:00 구간(KST 자정다음날 09:00)을 포함하는 테스트를 통해 요청 날짜가 KST 기준으로 올바르게 유지되는지 검증해야 한다.

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

In `@k6/scripts/session9/ranking-api-load.js` around lines 25 - 26, TODAY is
computed using toISOString() which returns UTC and can yield the wrong KST date;
replace the current TODAY definition with a KST-aware calculation (either
implement a seoulDateString(date = new Date()) helper that adds 9 hours and
formats YYYYMMDD, or accept BASE_DATE via env and fall back to that helper) and
use that helper to set TODAY; update any callers expecting TODAY (the TODAY
constant in this file) so they use the new KST-derived value and add tests or k6
scenarios that exercise UTC windows (UTC 00:00–09:00 and 15:00–24:00) to verify
the date stays KST-correct.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java-156-162 (1)

156-162: ⚠️ Potential issue | 🟠 Major

occurredAt 파싱 실패를 현재 시각으로 대체하면 날짜 버킷이 오염된다

지금처럼 occurredAt 오류를 LocalDateTime.now()로 바꾸면, 지연 소비나 재처리 중인 과거 이벤트가 오늘 데이터로 적재된다. 운영에서는 이 왜곡이 일간 Redis 랭킹뿐 아니라 product_metrics_daily 기반의 주/월 배치 집계까지 영구히 오염시킨다. 실패 시 레코드를 드롭하거나, 최소한 Kafka 레코드 timestamp를 fallback으로 사용해 원래 이벤트 시점을 최대한 보존하는 편이 안전하다. 추가로 잘못된 occurredAt와 자정 경계 이벤트가 기대한 metricDate로 분류되는 테스트를 넣는 것이 좋다.

🤖 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/interfaces/consumer/CatalogEventProcessor.java`
around lines 156 - 162, The parseOccurredAt method currently swallows parsing
errors and returns LocalDateTime.now(), which can corrupt time-based buckets;
change parseOccurredAt(Map<String,Object> message) so that on parse failure it
does NOT default to now but instead either throws an exception to drop the
record upstream or returns a fallback based on the Kafka record timestamp (use
the ConsumerRecord timestamp if available) to preserve the original event time;
update callers of parseOccurredAt (e.g., wherever parseOccurredAt is invoked) to
handle the thrown exception by dropping/logging the message or to accept the
Kafka-timestamp fallback, and add unit tests for invalid occurredAt and events
straddling midnight to assert correct metricDate assignment.
k6/scripts/session9/ranking-e2e-accuracy.js-51-80 (1)

51-80: ⚠️ Potential issue | 🟠 Major

정확성 스크립트가 주문 경로를 실제로 검증하지 않는다

주문 시나리오는 주석만 있고 실제 요청이 없으며, 중복 좋아요 응답도 검사하지 않는다. 이 상태에서는 주문 이벤트 적재가 깨져도 스크립트가 성공해서 운영 배포 전 정확성 회귀를 놓치게 된다. PRODUCT_3에 대한 실제 주문 생성 호출을 추가하고, 전파 후 PRODUCT_3 > PRODUCT_2 > PRODUCT_1 같은 기대 순위 또는 점수 관계를 명시적으로 검증해야 한다. 추가로 duplicate like가 허용 상태코드 범위를 벗어나면 즉시 실패하도록 체크를 넣는 것이 좋다.

apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java-31-40 (1)

31-40: ⚠️ Potential issue | 🟠 Major

비정상 증분값을 허용하면 누적/일별 테이블이 함께 오염된다

incrementViewCountBy와 날짜 기반 incrementOrderCount가 입력값을 검증하지 않고 두 저장소에 그대로 반영한다. 소비자 버그나 독성 메시지로 count <= 0 또는 amount < 0가 들어오면 누적 지표와 일별 스냅샷이 동시에 깨지고, 이후 주/월 배치 집계까지 연쇄 오염된다. 서비스 경계에서 값을 선검증하고, 실패 시 repository 호출 전에 예외로 중단하는 편이 안전하다. 추가로 0/음수 입력에서 두 repository 모두 호출되지 않는 테스트를 넣는 것이 좋다.

수정 예시다
     public void incrementViewCountBy(Long productId, int count, LocalDate metricDate) {
+        if (count <= 0) {
+            throw new IllegalArgumentException("count must be positive");
+        }
         productMetricsRepository.incrementViewCountBy(productId, count);
         productMetricsDailyRepository.incrementViewCountBy(productId, metricDate, count);
     }
@@
     public void incrementOrderCount(Long productId, long amount, LocalDate metricDate) {
+        if (amount < 0) {
+            throw new IllegalArgumentException("amount must be non-negative");
+        }
         productMetricsRepository.incrementOrderCount(productId, amount);
         productMetricsDailyRepository.incrementOrderCount(productId, metricDate, amount);
     }

Also applies to: 77-80

🤖 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/ProductMetricsService.java`
around lines 31 - 40, Validate input at the service boundary: in both
incrementViewCountBy(Long productId, int count) and incrementViewCountBy(Long
productId, int count, LocalDate metricDate) (and the analogous
incrementOrderCount/amount methods referenced), check that count > 0 (and amount
>= 0 for monetary/order-amount methods) and throw an IllegalArgumentException
(or a domain-specific exception) before calling productMetricsRepository or
productMetricsDailyRepository (and the corresponding order repositories), so
neither repository is invoked on invalid input; add unit tests asserting that
0/negative inputs throw and that repositories are not called.
apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java-148-168 (1)

148-168: ⚠️ Potential issue | 🟠 Major

좋아요 계열 이벤트는 userId 없으면 파싱 단계에서 버려야 한다

현재 구현은 PRODUCT_LIKED/PRODUCT_UNLIKED인데도 userId == null인 레코드를 그대로 통과시킨다. 이 경우 운영에서는 DB 좋아요 수가 먼저 반영된 뒤 Redis 멱등 처리에서 실패하거나 null 사용자로 잘못 집계되어, 일간 랭킹과 주/월 집계용 지표가 서로 어긋날 수 있다. 파싱 단계에서 좋아요 계열 이벤트는 userId를 필수값으로 검증하고, 누락 시 실패 메트릭을 남긴 뒤 드롭하는 편이 안전하다. 추가로 userId가 없는 LIKE/UNLIKE 레코드가 들어오면 ProductMetricsServiceRankingService가 호출되지 않는 테스트를 넣는 것이 좋다.

수정 예시다
-        return new ParsedEvent(
-                eventType,
-                productIdNum.longValue(),
-                parseUserId(message),
-                parseOccurredAt(message)
-        );
+        Long userId = parseUserId(message);
+        if (("PRODUCT_LIKED".equals(eventType) || "PRODUCT_UNLIKED".equals(eventType)) && userId == null) {
+            log.warn("[CatalogProcessor] LIKE 계열 이벤트의 userId 누락: {}", message);
+            consumerMetrics.recordCatalogFailed();
+            return null;
+        }
+
+        return new ParsedEvent(
+                eventType,
+                productIdNum.longValue(),
+                userId,
+                parseOccurredAt(message)
+        );
🤖 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/interfaces/consumer/CatalogEventProcessor.java`
around lines 148 - 168, Parsing currently allows PRODUCT_LIKED/PRODUCT_UNLIKED
events with null userId to pass through; change the parsing logic (where
ParsedEvent is constructed and in parseUserId) to treat userId as required for
like/unlike events: if eventType == PRODUCT_LIKED or PRODUCT_UNLIKED and
parseUserId(...) returns null, record a failure metric (e.g., increment a
"missing_user_id_for_like" counter) and abort parsing/return null (so the event
is dropped and downstream handlers are not invoked). Update parseUserId to keep
returning Long or null, but enforce the check immediately before creating the
ParsedEvent (the location that calls new ParsedEvent(...)) and ensure code paths
that drop the event do not call ProductMetricsService or RankingService; add
tests asserting ProductMetricsService and RankingService are NOT called when a
LIKE/UNLIKE record has no userId.
k6/scripts/session9/ranking-mixed-load.js-29-32 (1)

29-32: ⚠️ Potential issue | 🟠 Major

부하 테스트가 UTC 날짜로 실행되어 KST 기반 일간 랭킹 조회와 불일치한다

현재 TODAYtoISOString()으로 생성하므로 UTC 기준이다. 그러나 서버는 TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))로 KST를 사용하며, 랭킹 API는 date 파라미터로 일간 버킷을 결정한다. UTC와 KST 사이에 최대 9시간 차이가 있어 특히 자정 근처에서 이전 날짜의 랭킹 데이터를 조회하게 되므로, 캐시/Redis hit pattern과 응답 지연 시간 측정이 왜곡된다. 부하 테스트는 실제 운영 환경을 정확히 모사해야 하므로 날짜 생성을 수정해야 한다.

수정안: __ENV.TODAY로 KST 기준 날짜를 주입받거나, 클라이언트 기준 로컬 날짜(또는 UTC 오프셋을 적용한 KST 계산)로 변경한다. 추가로 UTC/KST 경계 시각(KST 자정 전후)에 요청 URL의 date 값이 예상과 일치하는지 검증하는 테스트를 추가한다.

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

In `@k6/scripts/session9/ranking-mixed-load.js` around lines 29 - 32, TODAY is
being generated in UTC via new Date().toISOString(), causing date mismatches vs
server KST; change the TODAY logic to prefer an injected __ENV.TODAY when
present and otherwise compute the KST date (UTC+9) for the local day (or use the
runtime's local date) so the test sends the same date bucket the server expects;
update the constant named TODAY and any calling code that builds the request URL
to use this KST-derived value and add a small assertion in the script to
log/verify the resolved date matches expected KST (especially around midnight)
before sending requests.
k6/scripts/session9/ranking-e2e-accuracy.js-24-25 (1)

24-25: ⚠️ Potential issue | 🟠 Major

K6 스크립트의 날짜 계산을 KST 기준으로 수정하고 자정 경계 테스트 추가

toISOString()은 UTC 기준이므로, Asia/Seoul 타임존에서 집계되는 백엔드 랭킹 시스템과 시간차가 발생한다. UTC 15:00~16:00 구간(KST 자정)에서 K6가 실행되면 어제 날짜를 조회하게 되어 테스트가 불안정해진다.

수정 방안:

  1. 테스트 날짜를 환경변수(__ENV.TODAY)로 주입받거나
  2. KST 오프셋(+9시간)을 반영하여 yyyyMMdd를 계산하는 헬퍼 함수 작성

추가로 UTC 15:00~16:00(KST 자정 경계) 구간에서 오늘·어제 날짜 조회 검증 테스트를 추가하면, 운영 중 자정 근처의 날짜 불일치 장애를 미리 방지할 수 있다.

(해당 패턴은 라인 143~145에도 동일하게 적용됨)

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

In `@k6/scripts/session9/ranking-e2e-accuracy.js` around lines 24 - 25, TODAY is
computed with toISOString() (UTC) causing off-by-one at KST midnight; change to
accept an env override (__ENV.TODAY) or compute KST by adding +9h before
formatting yyyyMMdd via a helper (e.g., replace current TODAY definition and any
duplicate at the second occurrence around lines 143-145), and add an E2E check
that runs logic for the UTC 15:00–16:00 boundary to assert both “today” and
“yesterday” are returned correctly when the KST date flips.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-196-202 (1)

196-202: ⚠️ Potential issue | 🟠 Major

windowDays를 상수 7로 고정하면 주간 메타데이터가 거짓이 된다.

초기 적재 직후나 누락 복구 상황에서는 실제 집계 일수가 7 미만일 수 있는데, 지금 응답은 항상 완성된 7일 데이터처럼 보이게 된다. 운영에서는 클라이언트의 “진행 중” 표시와 캐시 판단이 틀어지고, 부분 집계 상태를 구분하기 어려워진다. 실제 집계에 사용한 distinct 일수를 배치 결과에 저장하거나 조회 시 계산해서 내려주고, 3일치 데이터만 있을 때 windowDays=3이 반환되는 테스트를 추가해야 한다.

🤖 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/RankingFacade.java`
around lines 196 - 202, computeWeeklyWindowDays currently returns the constant
WEEKLY_WINDOW_DAYS which hides partial aggregation; change
computeWeeklyWindowDays(LocalDate baseDate) to compute the actual distinct
number of days used in the weekly aggregation (e.g., by reading the stored batch
result field that records distinct aggregation days or by querying the
underlying aggregation table for distinct dates between
baseDate.minusDays(WEEKLY_WINDOW_DAYS-1) and baseDate), return that dynamic
count instead of the constant, and ensure any code that sets the response field
windowDays uses computeWeeklyWindowDays; also add a unit/integration test that
simulates a partial-load (e.g., 3 distinct days) and asserts windowDays == 3.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-61-63 (1)

61-63: ⚠️ Potential issue | 🟠 Major

빈 페이지여도 이미 계산한 pagination 메타데이터는 유지해야 한다.

현재는 entries가 비면 직전에 계산한 totalElementstotalPages를 버리고 둘 다 0으로 내려준다. 운영에서는 범위를 벗어난 페이지 요청이나 필터링으로 content만 비는 케이스에서 클라이언트 페이지네이션이 흔들리고, 동일 조건 응답의 계약도 깨진다. 빈 content만 반환하되 메타데이터는 유지하도록 수정하고, totalCount > 0이지만 현재 페이지 결과가 비는 케이스를 테스트해야 한다.

수정 예시를 제안한다
         if (entries.isEmpty()) {
-            return MvRankingPage.daily(date, page, size, 0, 0, List.of());
+            return MvRankingPage.daily(date, page, size, totalElements, totalPages, List.of());
         }
🤖 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/RankingFacade.java`
around lines 61 - 63, The current early-return in RankingFacade when
entries.isEmpty() discards the previously computed pagination metadata and
returns zeros; change the logic so pagination metadata (e.g.,
totalElements/totalPages/totalCount computed earlier) is preserved and only the
content is empty: ensure you compute and store totalElements/totalPages (and
totalCount if used) before the entries.isEmpty() check and call
MvRankingPage.daily(date, page, size, totalElements, totalPages, List.of())
instead of zeros; also add a unit/integration test for the case where totalCount
> 0 but the requested page yields empty entries to assert metadata is unchanged
while content is empty.
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java-41-46 (2)

41-46: ⚠️ Potential issue | 🟠 Major

기간별 @Cacheable 캐시가 작동하지 않는다.

getRankings 메서드가 같은 빈 내부의 getDailyRankings, getWeeklyRankings, getMonthlyRankings를 직접 호출하기 때문에 Spring 프록시를 우회한다. 이 때문에 @Cacheable 어노테이션이 실제로 적용되지 않으며, 운영 환경에서 동일 조건의 요청이 들어와도 Redis와 MV 조회가 매번 반복 실행된다. 트래픽이 몰릴 때 응답 시간과 저장소 부하가 함께 증가한다.

기간별 조회 로직을 별도 빈으로 분리하거나, 프록시된 자신의 메서드를 명시적으로 호출하도록 구조를 변경해야 한다. 동일 파라미터로 두 번 요청했을 때 rankingService가 한 번만 호출되는지 검증하는 캐시 테스트를 추가해야 한다.

🤖 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/RankingFacade.java`
around lines 41 - 46, getRankings in RankingFacade currently calls
getDailyRankings/getWeeklyRankings/getMonthlyRankings directly, bypassing
Spring's cache proxy so `@Cacheable` isn't applied; fix by either extracting the
period-specific methods into a separate `@Service` bean (e.g.,
RankingPeriodService) and delegating from RankingFacade to that bean, or inject
the proxied self (RankingFacade self) and invoke self.getDailyRankings/
self.getWeeklyRankings/ self.getMonthlyRankings so calls go through the Spring
proxy; also add a unit/integration cache test that calls getRankings twice with
identical params and asserts rankingService (or the underlying repository) was
invoked only once.

41-46: ⚠️ Potential issue | 🟠 Major

공개 조회 메서드에 페이지 파라미터 검증이 없다.

size == 0이면 분모가 0이 되고, 음수 page/size는 Redis·DB offset과 rank 계산을 음수로 만든다. 운영에서는 잘못된 요청 하나로 500이 발생하거나 비정상 캐시 키가 쌓일 수 있다. 모든 공개 진입점에서 period/date != null, page >= 0, 1 <= size <= MAX_RANK를 검증하고 CoreException으로 실패시키며, 0·음수·null 입력에 대한 facade/controller 테스트를 추가해야 한다. Based on learnings, unified error handling should route through CoreException to ApiControllerAdvice to ensure a consistent response format.

Also applies to: 54-60, 90-95, 109-114

🤖 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/RankingFacade.java`
around lines 41 - 46, Public entry methods like getRankings, getDailyRankings,
getWeeklyRankings and getMonthlyRankings must validate inputs: ensure
period/date parameters are not null, enforce page >= 0 and 1 <= size <= MAX_RANK
(define or reference a constant MAX_RANK), and throw a CoreException on invalid
input so ApiControllerAdvice can produce a consistent error response; add
corresponding unit tests for facade and controller to cover null/zero/negative
values and out-of-range size, and update callers of getRankings (and the methods
at lines noted) to rely on the centralized validation rather than allowing
negative/zero values to reach Redis/DB or rank calculations.
🟡 Minor comments (12)
apps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.java-62-65 (1)

62-65: ⚠️ Potential issue | 🟡 Minor

카운트 기반 메트릭 기록은 0/음수 입력을 방어해야 한다.

운영 관점에서 Line 62, 65에 0 또는 음수가 유입되면 지표 왜곡으로 알람 신뢰도가 떨어질 수 있다. 수정안으로 count < 0은 예외로 즉시 실패시키고 count == 0은 no-op 처리하는 방어 로직을 넣는 것이 안전하다. 추가 테스트로 count=-1, count=0, count>0 각각의 동작을 검증해야 한다.

수정 예시
 public void recordCatalogProcessed(long count) { 
-    catalogProcessedCounter.increment(count); 
+    if (count < 0) throw new IllegalArgumentException("count must be >= 0");
+    if (count == 0) return;
+    catalogProcessedCounter.increment(count); 
 }
@@
 public void recordCatalogFailed(long count) { 
-    catalogFailedCounter.increment(count); 
+    if (count < 0) throw new IllegalArgumentException("count must be >= 0");
+    if (count == 0) return;
+    catalogFailedCounter.increment(count); 
 }
🤖 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/infrastructure/monitoring/ConsumerMetrics.java`
around lines 62 - 65, The two count-based metric methods
recordCatalogProcessed(long) and recordCatalogFailed(long) must guard against
non-positive inputs: if count < 0 throw an IllegalArgumentException immediately,
if count == 0 return as a no-op, otherwise call the underlying
counter.increment(count); update both methods to implement this defensive check
and add unit tests that assert IllegalArgumentException for count=-1, no counter
change for count=0, and normal increment behavior for count>0.
apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java-25-27 (1)

25-27: ⚠️ Potential issue | 🟡 Minor

기간 파싱의 대소문자 정규화는 Locale 고정이 필요하다.

운영 관점에서 서버 기본 Locale이 비영어권(예: Turkish)일 경우 Line 25의 toUpperCase()는 예기치 않은 변환을 만들어 간헐적 400 오류를 유발할 수 있다. 수정안으로 toUpperCase(Locale.ROOT)를 사용해 환경 독립적으로 고정해야 한다. 추가 테스트로 기본 Locale을 tr_TR로 설정한 뒤 "weekly", "monthly" 입력이 정상 파싱되는 케이스를 포함해야 한다.

수정 예시
+import java.util.Locale;
@@
-            return RankingPeriod.valueOf(raw.trim().toUpperCase());
+            return RankingPeriod.valueOf(raw.trim().toUpperCase(Locale.ROOT));
🤖 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/RankingPeriod.java`
around lines 25 - 27, The period parsing in RankingPeriod.valueOf currently uses
raw.trim().toUpperCase() which is locale-dependent; change it to
raw.trim().toUpperCase(Locale.ROOT) to make the normalization
environment-independent (update the import to java.util.Locale if needed), and
add unit tests for RankingPeriod parsing that set Locale.setDefault(new
Locale("tr","TR")) (or use Locale.forLanguageTag("tr-TR")) to assert that inputs
like "weekly" and "monthly" still parse correctly via RankingPeriod.valueOf or
the parsing method used.
k6/seed-session9.sh-15-31 (1)

15-31: ⚠️ Potential issue | 🟡 Minor

미사용 ADMIN_HEADER는 운영 혼선을 유발하므로 의도를 코드에 반영해야 한다.

Line 15의 헤더 변수가 실제 요청(Line 30-31)에 사용되지 않아, 운영 시 “관리자 헤더 기반 시드”로 오해하고 장애 분석 시간을 늘릴 수 있다.
수정안은 (1) 헤더가 불필요하면 변수 삭제, (2) 필요하면 curl에 -H "${ADMIN_HEADER}"를 추가하는 방식이다.
추가 테스트로 CI에 ShellCheck(SC2034)를 포함해 미사용 변수 재유입을 차단하는 것이 좋다.

수정 예시 diff
-ADMIN_HEADER="X-Loopers-Ldap: loopers.admin"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@k6/seed-session9.sh` around lines 15 - 31, The ADMIN_HEADER variable is
defined (ADMIN_HEADER="X-Loopers-Ldap: loopers.admin") but never used, causing
possible operational confusion; either remove the ADMIN_HEADER declaration if
admin header is not needed, or apply it to the user creation curl requests by
adding the header flag (use the ADMIN_HEADER variable) to the curl POST calls in
the user-creation loop (look for the curl that posts to
"${BASE_URL}/api/v1/users"); also add ShellCheck (or SC2034 check) to CI to
prevent reintroducing unused shell variables.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java-262-284 (1)

262-284: ⚠️ Potential issue | 🟡 Minor

LocalDate.now() 정확 매칭 스텁은 자정 경계에서 테스트 플래키를 유발한다.

Line 262와 Line 284에서 현재 시각값을 정확 매칭으로 스텁하면, CI 실행 시점이 자정을 기점으로 갈릴 때 간헐 실패 위험이 있다. 같은 파일의 Line 59에서는 이미 any()로 유연한 매칭을 사용 중이므로 일관성도 떨어진다.

수정안: any(LocalDate.class) + eq(101L)로 변경하고, verify(rankingService).getRank(any(LocalDate.class), eq(101L))로 호출 검증한다.

수정 예시 diff
-        when(rankingService.getRank(LocalDate.now(), 101L)).thenReturn(3L);
+        when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(3L);
@@
-        when(rankingService.getRank(LocalDate.now(), 101L)).thenReturn(null);
+        when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(null);
🤖 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/application/product/ProductFacadeTest.java`
around lines 262 - 284, The test uses LocalDate.now() as an exact matcher when
stubbing rankingService.getRank, which causes flakiness at midnight; update the
stub in ProductFacadeTest to use a flexible matcher like any(LocalDate.class)
combined with eq(101L) for the product id (i.e.,
when(rankingService.getRank(any(LocalDate.class), eq(101L))).thenReturn(...))
and adjust the verification to
verify(rankingService).getRank(any(LocalDate.class), eq(101L)) so the test no
longer depends on the exact current date, referencing rankingService.getRank,
LocalDate.now(), any(LocalDate.class), eq(101L), and
verify(rankingService).getRank.
apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/common/RankingScoreCalculatorTest.java-62-69 (1)

62-69: ⚠️ Potential issue | 🟡 Minor

테스트 설명이 실제 검증 조건과 반대다.

Line 62의 설명은 "고가 상품 소량 주문이 역전할 수 있다"인데, Line 69는 반대로 lowPriceBulk > highPrice만 검증한다. 운영에서는 이 테스트가 점수 정책의 의도를 잘못 문서화해 가중치 조정 시 회귀 판단을 오도할 수 있다. 현재 assertion이 맞다면 표시 이름과 메서드명을 그 의미에 맞게 바꾸고, 정말 역전 가능성을 보장하려는 의도라면 입력값과 비교 방향을 다시 구성하라. 추가로 "건수 우세가 유지되는 경우"와 "다른 지표를 더하면 역전 가능한 경우"를 분리한 테스트를 보강하는 것이 좋다.

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

64-69: ⚠️ Potential issue | 🟡 Minor

parseHourKey도 동일한 silent fallback 문제가 있다.

hour 파라미터가 10자리 숫자가 아니면 조용히 현재 시각으로 대체된다. hour=abc를 전달해도 에러 없이 현재 시각 랭킹이 반환된다.

🤖 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/RankingV1Controller.java`
around lines 64 - 69, parseHourKey silently falls back to current time when the
hour param is not a 10-digit numeric string, which hides invalid input; change
parseHourKey in RankingV1Controller to validate and fail fast by throwing a
clear 400-level exception (e.g., throw new
ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid hour format: must be 10
digits") or IllegalArgumentException) instead of returning LocalDateTime.now(),
and ensure callers rely on the exception to return a bad-request response.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/WriteMonthlyTopRanksTasklet.java-38-39 (1)

38-39: ⚠️ Potential issue | 🟡 Minor

baseDateParam null 검증이 필요하다.

Job 파라미터 누락 시 baseDateParam이 null이 되어 Line 39에서 NullPointerException이 발생한다. 명확한 에러 메시지와 함께 실패하도록 검증 로직을 추가해야 한다.

🛡️ 제안: null 검증 추가
 `@Override`
 public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
+    if (baseDateParam == null || baseDateParam.isBlank()) {
+        throw new IllegalStateException("Job parameter 'baseDate' is required");
+    }
     LocalDate baseDate = LocalDate.parse(baseDateParam, DateTimeFormatter.BASIC_ISO_DATE);
🤖 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/monthly/step/WriteMonthlyTopRanksTasklet.java`
around lines 38 - 39, In WriteMonthlyTopRanksTasklet.execute, validate
baseDateParam before calling LocalDate.parse: check that baseDateParam is not
null (and not blank) and if invalid throw a clear runtime exception (e.g.,
IllegalArgumentException or JobParametersInvalidException) with a message like
"Missing or invalid job parameter: baseDateParam" so the job fails fast instead
of NPE; locate this check near the start of execute where baseDateParam is
referenced and ensure subsequent LocalDate.parse(baseDateParam,
DateTimeFormatter.BASIC_ISO_DATE) only runs after validation.
k6/scripts/session9/ranking-weight-accuracy.js-113-139 (1)

113-139: ⚠️ Potential issue | 🟡 Minor

상품 A/B 미발견 시 테스트가 실패하지 않는다.

Line 135-138에서 productAEntry 또는 productBEntry가 없으면 WARNING 로그만 출력하고 check()가 실행되지 않는다. 이 경우 테스트가 성공으로 끝나 정확성 검증 목적을 달성하지 못한다.

🛡️ 제안: 미발견 시 명시적 실패 처리
     if (productAEntry && productBEntry) {
         // ... existing checks
     } else {
         console.log('WARNING: Product A or B not found in ranking');
         console.log('Content:', JSON.stringify(content.slice(0, 5)));
+        check(null, {
+            'product A found in ranking': () => !!productAEntry,
+            'product B found in ranking': () => !!productBEntry,
+        });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@k6/scripts/session9/ranking-weight-accuracy.js` around lines 113 - 139, If
productAEntry or productBEntry is missing the test merely logs a warning and
skips check(), causing false positives; update the branch where
productAEntry/productBEntry are falsy to explicitly fail the test by invoking
k6's fail() with a clear message (or alternately call check(null, { 'missing
product A or B': () => false })) so the scenario always fails when either entry
is not found; modify the block referencing productAEntry, productBEntry and
check() to perform this explicit failure action instead of only logging.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java-71-79 (1)

71-79: ⚠️ Potential issue | 🟡 Minor

잘못된 date 형식에서 조용히 기본값 반환하는 것은 문제가 될 수 있다.

date=invalid를 전달하면 예외 없이 오늘 날짜로 조회된다. 클라이언트가 자신의 입력 오류를 인지하지 못하고 의도와 다른 결과를 받게 된다. 운영 중 디버깅이 어려워질 수 있다.

두 가지 선택지가 있다:

  1. 잘못된 형식에서 400 Bad Request 반환
  2. 현재 동작 유지하되 응답에 사용된 실제 date를 포함하여 클라이언트가 인지 가능하도록 함
💡 Option 1: 잘못된 형식에서 예외 발생
 private LocalDate parseDate(String dateStr) {
     if (dateStr == null || dateStr.isBlank()) {
         return LocalDate.now();
     }
-    try {
-        return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE);
-    } catch (DateTimeParseException e) {
-        return LocalDate.now();
-    }
+    return LocalDate.parse(dateStr, DateTimeFormatter.BASIC_ISO_DATE);
+    // DateTimeParseException은 ControllerAdvice에서 처리
 }
🤖 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/RankingV1Controller.java`
around lines 71 - 79, The parseDate method currently swallows parse errors and
returns LocalDate.now(), which hides client input mistakes; change it to throw a
400 Bad Request on invalid formats by replacing the catch block to throw new
ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format: " +
dateStr) (ensure imports for
org.springframework.web.server.ResponseStatusException and
org.springframework.http.HttpStatus), while keeping the existing behavior of
returning LocalDate.now() only when dateStr is null or blank; reference the
parseDate method to locate the change.
apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyProductMetricsReader.java-29-30 (1)

29-30: ⚠️ Potential issue | 🟡 Minor

baseDate 파라미터에 대한 null 검증이 필요하다.

baseDate가 null로 전달되면 Line 30에서 NullPointerException이 발생한다. 배치 Job 파라미터 파싱 실패 시 null이 전달될 수 있으므로 방어 코드가 필요하다.

🛡️ 제안: null 검증 추가
 public static JdbcPagingItemReader<ProductAggregate> create(DataSource dataSource, LocalDate baseDate) {
+    if (baseDate == null) {
+        throw new IllegalArgumentException("baseDate must not be null");
+    }
     LocalDate from = baseDate.minusDays(WINDOW_DAYS - 1L);
🤖 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/weekly/step/WeeklyProductMetricsReader.java`
around lines 29 - 30, In WeeklyProductMetricsReader.create, add a null check for
the baseDate parameter before computing 'from' to prevent a
NullPointerException; if baseDate is null, throw a clear
IllegalArgumentException (e.g. "baseDate must not be null") or return/handle a
sensible default, so the create(DataSource dataSource, LocalDate baseDate)
method validates input upfront and avoids using baseDate.minusDays(...) when
baseDate is null.
apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventProcessorTest.java-76-93 (1)

76-93: ⚠️ Potential issue | 🟡 Minor

테스트가 재처리 시나리오를 검증하지 않는다.

at-least-once 시맨틱과 DB 중복 증가 위험은 이미 설계에 반영되어 있다(JavaDoc 참고). ProductMetricsService가 자체 @Transactional를 소유하므로 각 호출마다 독립 트랜잭션이 시작되고, Redis 실패 후 메시지 재처리 시 DB 중복 증가가 발생한다. 하지만 현재 테스트는 Mock만 검증하며 실제 재처리 시나리오를 다루지 않는다.

다음을 추가로 검증하도록 개선하면 좋다:

  • 동일 배치를 두 번 처리하여 view count가 실제로 중복 증가하는지 확인
  • 운영 관점: view_count 재조정 배치가 존재하는지 확인하고, 없다면 like_count 보정과 유사하게 추가 필요
🤖 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/interfaces/consumer/CatalogEventProcessorTest.java`
around lines 76 - 93, Update the CatalogEventProcessorTest to simulate the
at-least-once reprocessing by calling
catalogEventProcessor.processBatch(records) twice (after configuring doThrow(new
RuntimeException("redis down")).when(rankingService).incrementScoreBatch(any())
to persist across calls) and assert that
productMetricsService.incrementViewCountBy(100L, 2) and
incrementViewCountBy(200L, 1) were effectively applied twice (e.g., total
increments reflect duplicate DB updates), and also verify
consumerMetrics.recordCatalogFailed was invoked for both attempts while
consumerMetrics.recordCatalogProcessed is never called; this ensures the test
covers duplicate DB increments on retry due to Redis failures and uses the
existing symbols catalogEventProcessor.processBatch,
rankingService.incrementScoreBatch, productMetricsService.incrementViewCountBy,
and consumerMetrics.recordCatalogFailed/recordCatalogProcessed to locate the
logic.
k6/scripts/session9/ranking-mixed-load.js-89-100 (1)

89-100: ⚠️ Potential issue | 🟡 Minor

정렬 검증이 첫 두 항목만 비교해 후반부 역전을 놓친다

현재 체크는 1, 2위만 비교하므로 3위 이하가 뒤섞여도 성공한다. 부하 상황에서 pagination 또는 직렬화 오류로 중간 이후 순서가 깨져도 감지하지 못해 운영 신뢰도가 낮아진다. 응답 배열 전체를 순회하며 인접 항목을 모두 비교하도록 바꾸는 편이 좋다. 추가로 3위 이후만 뒤바뀐 fixture에서도 체크가 실패하는 케이스를 넣는 것이 좋다.

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

In `@k6/scripts/session9/ranking-mixed-load.js` around lines 89 - 100, The
'ranking sorted' check only compares the first two items so it misses inversions
later in the list; update the check inside the check() call (the 'ranking
sorted' predicate that parses JSON and reads const items =
JSON.parse(r.body).data.content) to iterate the entire items array and verify
every adjacent pair satisfies items[i].score >= items[i+1].score, returning
false on the first violation and true if the loop completes; also add an
additional test case/fixture that exercises a response where the top-2 are
correct but a later item is out of order to ensure the new full-array check
fails as expected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: be975b9c-ce65-4ed6-bc60-79b16e9a3c5c

📥 Commits

Reviewing files that changed from the base of the PR and between aac425c and 176163e.

⛔ Files ignored due to path filters (4)
  • .claude/skills/design-qna/SKILL.md is excluded by !**/*.md and included by **
  • .claude/skills/test-report-skill/SKILL.md is excluded by !**/*.md and included by **
  • .claude/skills/test-report-skill/references/report-template.md is excluded by !**/*.md and included by **
  • k6/README.md is excluded by !**/*.md and included by **
📒 Files selected for processing (77)
  • .claude/settings.local.json
  • apps/commerce-api/src/main/java/com/loopers/application/event/ProductViewEventHandler.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/MvRankingPage.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvMonthlyRankId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvMonthlyRankModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvWeeklyRankId.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/MvWeeklyRankModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingPeriod.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvMonthlyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/MvWeeklyRankJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java
  • apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ranking/RankingV1ControllerE2ETest.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/ProductAggregate.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/ProductAggregateWithScore.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/RankingScoreCalculator.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/common/RankingWeights.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyCleanupTmpTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyProductMetricsReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyScoreProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/MonthlyTmpAggregateWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/monthly/step/WriteMonthlyTopRanksTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyCleanupTmpTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyProductMetricsReader.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyScoreProcessor.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WeeklyTmpAggregateWriter.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/weekly/step/WriteWeeklyTopRanksTasklet.java
  • apps/commerce-batch/src/main/resources/application.yml
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/common/RankingScoreCalculatorTest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/monthly/MonthlyRankingJobE2ETest.java
  • apps/commerce-batch/src/test/java/com/loopers/batch/job/ranking/weekly/WeeklyRankingJobE2ETest.java
  • apps/commerce-streamer/build.gradle.kts
  • apps/commerce-streamer/src/main/java/com/loopers/batch/RankingCarryOverScheduler.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyId.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyModel.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsDailyRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingService.java
  • apps/commerce-streamer/src/main/java/com/loopers/domain/ranking/RankingWeight.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsDailyRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/monitoring/ConsumerMetrics.java
  • apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ranking/RankingRepositoryImpl.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventProcessor.java
  • apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventProcessor.java
  • apps/commerce-streamer/src/test/java/com/loopers/batch/RankingCarryOverSchedulerTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingServiceTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/domain/ranking/RankingWeightTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/infrastructure/ranking/RankingRepositoryImplIntegrationTest.java
  • apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/CatalogEventProcessorTest.java
  • k6/scripts/session9/ranking-api-load.js
  • k6/scripts/session9/ranking-e2e-accuracy.js
  • k6/scripts/session9/ranking-event-throughput.js
  • k6/scripts/session9/ranking-mixed-load.js
  • k6/scripts/session9/ranking-weight-accuracy.js
  • k6/seed-session9.sh

Comment on lines +134 to +213
"Bash(grep:*)",
"Bash(cd:*)",
"Bash(chmod:*)",
"Bash(CLAUDE_HOOK_EVENT=Notification /home/ubuntu/.claude-hooks/notify.sh)",
"Bash(powershell.exe -NoProfile -Command \"[System.Reflection.Assembly]::LoadWithPartialName\\(''''System.Windows.Forms''''\\); [System.Windows.Forms.MessageBox]::Show\\(''''test''''\\)\")",
"Bash(powershell.exe -NoProfile -Command \"Get-Module -ListAvailable BurntToast 2>&1\")",
"Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/seed-session8.sh 2>&1)",
"Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/reset-queue.sh 2>&1)",
"Bash(xargs -r docker exec -i redis-master redis-cli DEL)",
"Bash(\\\\ k6:*)",
"Bash(while ! tail -1 /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bry70duvq.output)",
"Bash(do tail:*)",
"Bash(pkill -f CommerceApiApplication)",
"Bash(xargs -r kill -9)",
"Bash(while ! grep -q \"BUILD\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bifaewp3g.output)",
"Bash(do)",
"Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/seed-session8.sh 5000 2>&1)",
"Bash(while ! grep -q \"완료\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/bp4abvxma.output)",
"Bash(while ! grep -q \"SCALE=2000 완료\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/b3bscb81m.output)",
"Bash(pkill -f \"gradlew.*bootRun\")",
"Bash(while ! grep -q \"BUILD\" /tmp/claude-1000/-mnt-c-Users-kdj10-Git-loop-pack-be-l2-vol3-java/6670a94b-8a42-4f6b-a031-670ba1e9ef67/tasks/b4y73hks0.output)",
"Bash(while read:*)",
"Bash(do grep:*)",
"Bash(echo \"PID: $!\")",
"Bash(bash /mnt/c/Users/kdj10/Git/loop-pack-be-l2-vol3-java/k6/scripts/session8/reset-queue.sh)",
"Bash(k6 run:*)",
"Bash(tee /tmp/k6-top5-scale-1000.txt)",
"Bash(tee /tmp/k6-top5-scale-2000.txt)",
"Bash(pkill -f \"commerce-api\")",
"Bash(ulimit:*)",
"Bash(docker.exe ps:*)",
"Bash(sudo -n docker ps)",
"Bash(java -version)",
"Bash(sudo -n echo \"sudo ok\")",
"Bash(sudo -S echo \"sudo activated\")",
"Bash(sudo -S apt-get update -qq)",
"Bash(sudo -S apt-get install -y -qq openjdk-21-jdk-headless redis-tools curl gnupg lsb-release apt-transport-https ca-certificates)",
"Bash(sudo -S gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg)",
"Bash(sudo -S tee /etc/apt/sources.list.d/k6.list)",
"Bash(sudo -S apt-get install -y -qq k6)",
"Bash(sudo -S:*)",
"Bash(size=5 2>/dev/null)",
"Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo '1234')",
"Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo \"Queue reset done\")",
"Bash(xargs -r -L 100 sudo docker exec -i redis-master redis-cli DEL echo \"reset done\" cd ~/projects/loop-pack-be-l2-vol3-java)",
"Bash(xargs -r -L 200 sudo docker exec -i redis-master redis-cli DEL echo '1234')",
"Bash(xargs -r -L 200 sudo docker exec -i redis-master redis-cli DEL cd ~/projects/loop-pack-be-l2-vol3-java)",
"Bash(CLAUDE_HOOK_EVENT=Notification /home/mdskkdj/.claude-hooks/notify.sh:*)",
"Bash(CLAUDE_HOOK_EVENT=Stop /home/mdskkdj/.claude-hooks/notify.sh:*)",
"Bash(echo \"exit: $?\")",
"Bash(powershell.exe:*)",
"Bash(size=1 2>/dev/null)",
"Bash(xargs -r -L 500 sudo docker exec -i redis-master redis-cli DEL sleep 1 k6 run -e POLLERS=500 -e ORDERERS=20 k6/scripts/session8/L3-queue-mixed-load.js)",
"Bash(xargs -r -L 500 sudo docker exec -i redis-master redis-cli DEL sleep 1 k6 run -e POLLERS=2000 -e ORDERERS=50 k6/scripts/session8/L3-queue-mixed-load.js)",
"Read(//home/mdskkdj/projects/loop-pack-be-l2-vol3-java/**)",
"Bash(echo \"bootRun PID: $!\")",
"Bash(bash k6/scripts/session8/seed-session8.sh 10000)",
"Bash(xargs -P 200 -I '{}' curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H 'X-Loopers-LoginId: k6user{}' -H 'X-Loopers-LoginPw: Test1234!')",
"Bash(bash k6/scripts/session8/reset-queue.sh)",
"Bash(echo \"ZADD 완료: $\\(docker exec redis-master redis-cli ZCARD order:waiting-queue\\)\")",
"Bash(xargs -r kill sleep 3 bash k6/scripts/session8/reset-queue.sh echo \"Redis queue cleared. Waiting 5s for server to stabilize...\" sleep 5)",
"Bash(xargs -P 100 -I '{}' curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H 'X-Loopers-LoginId: k6user{}' -H 'X-Loopers-LoginPw: Test1234!')",
"Bash(xargs -P 100 -I {} curl -s -o /dev/null http://localhost:8080/api/v1/queue/position -H \"X-Loopers-LoginId: k6user{}\" -H \"X-Loopers-LoginPw: Test1234!\")",
"Bash(xargs -r kill)",
"Bash(echo \"PID=$!\")",
"Bash(git config:*)",
"Bash(echo \"API PID: $!\")",
"Bash(echo \"Streamer PID: $!\")",
"Bash(bash k6/seed.sh)",
"Bash(bash k6/seed-session9.sh)",
"Bash(sed:*)",
"Bash(pkill -f \"commerce-api:bootRun\")",
"Bash(pkill -f \"commerce-streamer:bootRun\")",
"Bash(pkill -f \"GradleDaemon\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d['meta']['result'], d['data']['productName']\\)\")",
"Bash(lsof -ti:8081)",
"Bash(pkill -9 -f \"commerce-api\")",
"Bash(pkill -9 -f \"commerce-streamer\")",
"Bash(pkill -9 -f \"GradleDaemon\")",
"Bash(git -C /mnt/c/Users/MDSK011/IdeaProjects/loop-pack-be-l2-vol3-java log --oneline -1)"
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 | 🔴 Critical

허용 범위가 너무 넓어 사실상 임의 명령 실행 권한이 된다

Bash(grep:*), Bash(cd:*), Bash(chmod:*), Bash(k6 run:*), Bash(powershell.exe:*), Bash(sudo -S:*) 같은 패턴은 세션8/9 로드 테스트를 넘어서 로컬 호스트에서 임의 명령 실행과 권한 상승까지 열어 둔다. 운영 관점에서는 재현성과 감사 가능성이 무너지고, 잘못된 프롬프트 한 번으로 개발 환경 상태가 바뀔 수 있다. 필요한 seed/reset/load 스크립트만 정확한 경로와 고정 인자로 허용하고, 일반화된 sudo -S:*powershell.exe:* 항목은 제거하는 편이 안전하다. 추가로 의도한 k6 시나리오는 계속 실행되고, 무관한 임의 명령은 차단되는지 확인하는 회귀 점검이 필요하다.

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

In @.claude/settings.local.json around lines 134 - 213, The allowed Bash
patterns in .claude/settings.local.json are too broad (e.g., "Bash(grep:*)",
"Bash(cd:*)", "Bash(chmod:*)", "Bash(k6 run:*)", "Bash(powershell.exe:*)",
"Bash(sudo -S:*)") which effectively permit arbitrary command execution;
restrict the entries by removing or replacing wildcard patterns with exact,
fixed command lines for only the intended scripts (e.g., the specific seed/reset
scripts and the exact k6 invocation paths), delete general-purpose patterns like
"Bash(powershell.exe:*)" and "Bash(sudo -S:*)" and any generic "Bash(...:*)"
that match broad shells, and add a regression check to verify the intended k6
seed/reset scripts still run while arbitrary commands are blocked.

Comment on lines +61 to +109
@Bean(JOB_NAME)
public Job monthlyRankingJob(
Step monthlyCleanupTmpStep,
Step monthlyAggregateStep,
Step writeMonthlyTopRanksStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
.incrementer(new RunIdIncrementer())
.listener(jobListener)
.start(monthlyCleanupTmpStep)
.next(monthlyAggregateStep)
.next(writeMonthlyTopRanksStep)
.build();
}

@Bean(STEP_CLEANUP)
public Step monthlyCleanupTmpStep(MonthlyCleanupTmpTasklet tasklet) {
return new StepBuilder(STEP_CLEANUP, jobRepository)
.tasklet(tasklet, txManager)
.listener(stepListener)
.build();
}

@Bean(STEP_AGGREGATE)
public Step monthlyAggregateStep(
JdbcPagingItemReader<ProductAggregate> monthlyReader,
MonthlyScoreProcessor processor,
JdbcBatchItemWriter<ProductAggregateWithScore> monthlyWriter
) {
return new StepBuilder(STEP_AGGREGATE, jobRepository)
.<ProductAggregate, ProductAggregateWithScore>chunk(CHUNK_SIZE, txManager)
.reader(monthlyReader)
.processor(processor)
.writer(monthlyWriter)
.faultTolerant()
.retryLimit(RETRY_LIMIT)
.retry(CannotAcquireLockException.class)
.retry(TransientDataAccessResourceException.class)
.listener(stepListener)
.build();
}

@Bean(STEP_WRITE_TOP)
public Step writeMonthlyTopRanksStep(WriteMonthlyTopRanksTasklet tasklet) {
return new StepBuilder(STEP_WRITE_TOP, jobRepository)
.tasklet(tasklet, txManager)
.listener(stepListener)
.build();
}
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 | 🔴 Critical

공유 tmp 테이블 때문에 동일 월간 잡을 동시에 실행하면 결과가 섞일 수 있다.

현재 구성은 RunIdIncrementer로 동일 잡의 재실행을 허용하면서, 첫 step에서 전역 tmp 테이블을 비우고 이후 step들이 그 테이블을 계속 사용한다. 운영에서는 백필이나 수동 재처리 중 다른 실행이 중간 집계 데이터를 지워서 mv_product_rank_monthly가 조용히 오염될 수 있다. 실행을 직렬화하거나, tmp row를 jobExecutionId 또는 baseDate로 분리하고 후속 step이 동일 실행의 row만 읽도록 바꿔야 한다. 서로 다른 baseDate의 두 월간 잡을 병렬로 실행했을 때 하나가 차단되거나 결과가 완전히 분리되는 E2E 테스트도 추가해야 한다.

🤖 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/monthly/MonthlyRankingJobConfig.java`
around lines 61 - 109, The job allows concurrent runs with RunIdIncrementer
while monthlyCleanupTmpStep empties a shared tmp table, causing cross-run data
contamination; change the flow so tmp rows are namespaced per run (e.g., add
jobExecutionId or baseDate column) and stop truncating the global table in
monthlyCleanupTmpStep; update the producer (monthlyAggregateStep /
MonthlyScoreProcessor) to write tmp rows tagged with jobExecutionId/baseDate and
update the reader used by monthlyAggregateStep
(JdbcPagingItemReader<ProductAggregate>) and writeMonthlyTopRanksStep
(WriteMonthlyTopRanksTasklet) to filter/select only rows for the current
jobExecutionId/baseDate, or alternatively enforce single concurrent execution by
removing RunIdIncrementer and serializing starts; add an E2E test that runs two
monthly jobs with different baseDate in parallel and asserts they either block
or produce fully isolated results (no cross-contamination).

Comment on lines +66 to +114
@Bean(JOB_NAME)
public Job weeklyRankingJob(
Step weeklyCleanupTmpStep,
Step weeklyAggregateStep,
Step writeWeeklyTopRanksStep
) {
return new JobBuilder(JOB_NAME, jobRepository)
.incrementer(new RunIdIncrementer())
.listener(jobListener)
.start(weeklyCleanupTmpStep)
.next(weeklyAggregateStep)
.next(writeWeeklyTopRanksStep)
.build();
}

@Bean(STEP_CLEANUP)
public Step weeklyCleanupTmpStep(WeeklyCleanupTmpTasklet tasklet) {
return new StepBuilder(STEP_CLEANUP, jobRepository)
.tasklet(tasklet, txManager)
.listener(stepListener)
.build();
}

@Bean(STEP_AGGREGATE)
public Step weeklyAggregateStep(
JdbcPagingItemReader<ProductAggregate> weeklyReader,
WeeklyScoreProcessor processor,
JdbcBatchItemWriter<ProductAggregateWithScore> weeklyWriter
) {
return new StepBuilder(STEP_AGGREGATE, jobRepository)
.<ProductAggregate, ProductAggregateWithScore>chunk(CHUNK_SIZE, txManager)
.reader(weeklyReader)
.processor(processor)
.writer(weeklyWriter)
.faultTolerant()
.retryLimit(RETRY_LIMIT)
.retry(CannotAcquireLockException.class)
.retry(TransientDataAccessResourceException.class)
.listener(stepListener)
.build();
}

@Bean(STEP_WRITE_TOP)
public Step writeWeeklyTopRanksStep(WriteWeeklyTopRanksTasklet tasklet) {
return new StepBuilder(STEP_WRITE_TOP, jobRepository)
.tasklet(tasklet, txManager)
.listener(stepListener)
.build();
}
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 | 🔴 Critical

공유 tmp 테이블 때문에 동일 주간 잡을 동시에 실행하면 결과가 섞일 수 있다.

현재 구성은 RunIdIncrementer로 동일 잡의 재실행을 허용하면서, 첫 step에서 전역 tmp 테이블을 비우고 이후 step들이 그 테이블을 계속 사용한다. 운영에서는 백필이나 수동 재처리 중 다른 실행이 중간 집계 데이터를 지워서 mv_product_rank_weekly가 조용히 오염될 수 있다. 실행을 직렬화하거나, tmp row를 jobExecutionId 또는 baseDate로 분리하고 후속 step이 동일 실행의 row만 읽도록 바꿔야 한다. 서로 다른 baseDate의 두 주간 잡을 병렬로 실행했을 때 하나가 차단되거나 결과가 완전히 분리되는 E2E 테스트도 추가해야 한다.

🤖 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/weekly/WeeklyRankingJobConfig.java`
around lines 66 - 114, The job currently uses a global tmp table
(mv_product_rank_weekly) and RunIdIncrementer which allows concurrent runs to
clobber each other; change the tmp-row lifecycle to be execution-scoped: update
WeeklyCleanupTmpTasklet (used by weeklyCleanupTmpStep) to NOT truncate the whole
table but to insert/delete only rows tagged with the current jobExecutionId or
the required baseDate job parameter, and update readers/writers (the
JdbcPagingItemReader injected into weeklyAggregateStep and the writer in
WriteWeeklyTopRanksTasklet / writeWeeklyTopRanksStep) to filter/select by that
same jobExecutionId or baseDate so subsequent steps only touch the current run’s
rows; ensure the Job is supplied a mandatory baseDate job parameter (or
derive/propagate jobExecutionId) instead of relying on RunIdIncrementer for
isolation, and add an E2E test that runs two weekly jobs in parallel with
different baseDate values to assert they either serialize or fully isolate
results.

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.

1 participant